@amritk/lint 0.0.0 → 0.1.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/core/document.d.ts +19 -0
- package/dist/core/document.js +11 -0
- package/dist/core/formats.d.ts +9 -0
- package/dist/core/formats.js +14 -0
- package/dist/core/glob.d.ts +4 -0
- package/dist/core/glob.js +48 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.js +11 -0
- package/dist/core/jsonpath.d.ts +59 -0
- package/dist/core/jsonpath.js +415 -0
- package/dist/core/lint.d.ts +57 -0
- package/dist/core/lint.js +82 -0
- package/dist/core/plugin.d.ts +59 -0
- package/dist/core/plugin.js +25 -0
- package/dist/core/pointers.d.ts +35 -0
- package/dist/core/pointers.js +169 -0
- package/dist/core/ruleset.d.ts +52 -0
- package/dist/core/ruleset.js +170 -0
- package/dist/core/runner.d.ts +21 -0
- package/dist/core/runner.js +222 -0
- package/dist/core/types.d.ts +170 -0
- package/dist/core/types.js +1 -0
- package/dist/core/validate-ruleset.d.ts +14 -0
- package/dist/core/validate-ruleset.js +105 -0
- package/dist/fix/apply.d.ts +21 -0
- package/dist/fix/apply.js +51 -0
- package/dist/fix/index.d.ts +3 -0
- package/dist/fix/index.js +2 -0
- package/dist/fix/plugin.d.ts +18 -0
- package/dist/fix/plugin.js +21 -0
- package/dist/fix/types.d.ts +39 -0
- package/dist/fix/types.js +0 -0
- package/dist/functions/alphabetical.d.ts +8 -0
- package/dist/functions/alphabetical.js +28 -0
- package/dist/functions/casing.d.ts +14 -0
- package/dist/functions/casing.js +30 -0
- package/dist/functions/defined.d.ts +3 -0
- package/dist/functions/defined.js +6 -0
- package/dist/functions/enumeration.d.ts +5 -0
- package/dist/functions/enumeration.js +10 -0
- package/dist/functions/falsy.d.ts +3 -0
- package/dist/functions/falsy.js +6 -0
- package/dist/functions/index.d.ts +16 -0
- package/dist/functions/index.js +42 -0
- package/dist/functions/length.d.ts +6 -0
- package/dist/functions/length.js +27 -0
- package/dist/functions/pattern.d.ts +6 -0
- package/dist/functions/pattern.js +20 -0
- package/dist/functions/schema.d.ts +8 -0
- package/dist/functions/schema.js +36 -0
- package/dist/functions/truthy.d.ts +3 -0
- package/dist/functions/truthy.js +6 -0
- package/dist/functions/typed-enum.d.ts +3 -0
- package/dist/functions/typed-enum.js +34 -0
- package/dist/functions/undefined.d.ts +6 -0
- package/dist/functions/undefined.js +9 -0
- package/dist/functions/unreferenced-reusable-object.d.ts +8 -0
- package/dist/functions/unreferenced-reusable-object.js +37 -0
- package/dist/functions/xor.d.ts +7 -0
- package/dist/functions/xor.js +11 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +168 -0
- package/dist/parsers/edit-model.d.ts +69 -0
- package/dist/parsers/edit-model.js +326 -0
- package/dist/parsers/index.d.ts +18 -0
- package/dist/parsers/index.js +21 -0
- package/dist/parsers/json.d.ts +3 -0
- package/dist/parsers/json.js +38 -0
- package/dist/parsers/lines.d.ts +13 -0
- package/dist/parsers/lines.js +28 -0
- package/dist/parsers/types.d.ts +50 -0
- package/dist/parsers/types.js +8 -0
- package/dist/parsers/yaml.d.ts +6 -0
- package/dist/parsers/yaml.js +65 -0
- package/package.json +3 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type IDiagnostic, type ILocation, type IParserOptions, type JsonPath, type ParserFormat } from '../parsers/index.js';
|
|
2
|
+
/** Options for {@link createDocument}: the parser options plus a display `source` and an explicit `format`. */
|
|
3
|
+
export type IDocumentOptions = IParserOptions & {
|
|
4
|
+
source?: string;
|
|
5
|
+
format?: ParserFormat;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* A source string paired with its parsed data and the source map that resolves
|
|
9
|
+
* JSON paths back to line:column ranges. Parsing happens up front in
|
|
10
|
+
* {@link createDocument}, so every field here is ready to read.
|
|
11
|
+
*/
|
|
12
|
+
export type Document<T = unknown> = {
|
|
13
|
+
readonly source?: string | undefined;
|
|
14
|
+
readonly data: T;
|
|
15
|
+
readonly diagnostics: IDiagnostic[];
|
|
16
|
+
getLocationForJsonPath(path: JsonPath, closest?: boolean): ILocation | undefined;
|
|
17
|
+
};
|
|
18
|
+
/** Parses `input` into a {@link Document} with a source map for position lookups. */
|
|
19
|
+
export declare const createDocument: <T = unknown>(input: string, options?: IDocumentOptions) => Document<T>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { parseWithPointers, } from '../parsers/index.js';
|
|
2
|
+
/** Parses `input` into a {@link Document} with a source map for position lookups. */
|
|
3
|
+
export const createDocument = (input, options = {}) => {
|
|
4
|
+
const parsed = parseWithPointers(input, options);
|
|
5
|
+
return {
|
|
6
|
+
source: options.source,
|
|
7
|
+
data: parsed.data,
|
|
8
|
+
diagnostics: parsed.diagnostics,
|
|
9
|
+
getLocationForJsonPath: (path, closest = false) => parsed.getLocationForJsonPath(path, closest),
|
|
10
|
+
};
|
|
11
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A predicate that reports whether a parsed document matches a given format. */
|
|
2
|
+
export type Format = (document: unknown) => boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Returns the set of registered format names that match the document. The
|
|
5
|
+
* `formats` registry is supplied by the caller (a preset provides its own
|
|
6
|
+
* format detectors); the engine itself is format-agnostic, so the
|
|
7
|
+
* default registry is empty and rules with no `formats` gate run regardless.
|
|
8
|
+
*/
|
|
9
|
+
export declare const detectFormats: (document: unknown, formats?: Record<string, Format>) => Set<string>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the set of registered format names that match the document. The
|
|
3
|
+
* `formats` registry is supplied by the caller (a preset provides its own
|
|
4
|
+
* format detectors); the engine itself is format-agnostic, so the
|
|
5
|
+
* default registry is empty and rules with no `formats` gate run regardless.
|
|
6
|
+
*/
|
|
7
|
+
export const detectFormats = (document, formats = {}) => {
|
|
8
|
+
const matched = new Set();
|
|
9
|
+
for (const [name, detector] of Object.entries(formats)) {
|
|
10
|
+
if (detector(document))
|
|
11
|
+
matched.add(name);
|
|
12
|
+
}
|
|
13
|
+
return matched;
|
|
14
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Compiles a glob pattern (`**`, `*`, `?`) into an anchored RegExp. */
|
|
2
|
+
export declare const globToRegExp: (glob: string) => RegExp;
|
|
3
|
+
/** Returns true if `path` matches any of the glob patterns. */
|
|
4
|
+
export declare const matchesGlob: (path: string, patterns: string[]) => boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal glob → RegExp for matching override `files` patterns against document
|
|
3
|
+
* paths. Supports `**` (any path segments), `*` (within a segment), and `?`.
|
|
4
|
+
*/
|
|
5
|
+
const REGEXP_SPECIAL = /[\\^$.*+?()[\]{}|/]/;
|
|
6
|
+
/** Compiles a glob pattern (`**`, `*`, `?`) into an anchored RegExp. */
|
|
7
|
+
export const globToRegExp = (glob) => {
|
|
8
|
+
let source = '';
|
|
9
|
+
for (let i = 0; i < glob.length; i++) {
|
|
10
|
+
const char = glob[i];
|
|
11
|
+
if (char === '*') {
|
|
12
|
+
if (glob[i + 1] === '*') {
|
|
13
|
+
i++;
|
|
14
|
+
if (glob[i + 1] === '/') {
|
|
15
|
+
i++;
|
|
16
|
+
source += '(?:.*/)?';
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
source += '.*';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
source += '[^/]*';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (char === '?') {
|
|
27
|
+
source += '[^/]';
|
|
28
|
+
}
|
|
29
|
+
else if (char && REGEXP_SPECIAL.test(char)) {
|
|
30
|
+
source += `\\${char}`;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
source += char;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return new RegExp(`^${source}$`);
|
|
37
|
+
};
|
|
38
|
+
/** Returns true if `path` matches any of the glob patterns. */
|
|
39
|
+
export const matchesGlob = (path, patterns) => {
|
|
40
|
+
const basename = path.split('/').pop() ?? path;
|
|
41
|
+
return patterns.some((pattern) => {
|
|
42
|
+
const regex = globToRegExp(pattern);
|
|
43
|
+
if (regex.test(path))
|
|
44
|
+
return true;
|
|
45
|
+
// A pattern without a slash also matches the basename.
|
|
46
|
+
return !pattern.includes('/') && regex.test(basename);
|
|
47
|
+
});
|
|
48
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createDocument, type Document, type IDocumentOptions } from './document.js';
|
|
2
|
+
export { detectFormats, type Format } from './formats.js';
|
|
3
|
+
export { globToRegExp, matchesGlob } from './glob.js';
|
|
4
|
+
export { type CompiledPath, compileQuery, type IQueryMatch, query, queryCompiled, queryMany } from './jsonpath.js';
|
|
5
|
+
export { type LintOptions, type LintResolver, type LintResult, lint, lintWithResult } from './lint.js';
|
|
6
|
+
export { type LintPlugin, type LintPluginContext, type LintPluginResult, type PluginRunResult, runPlugins, } from './plugin.js';
|
|
7
|
+
export { pointerToPath, resolveSourceOrigin, resolveSourceOriginFromMap, resolveSourcePath, } from './pointers.js';
|
|
8
|
+
export { type AliasDefinition, createRuleset, type ExtendModifier, type ExtendResolver, type ResolvedExtend, type Ruleset, type RulesetOptions, } from './ruleset.js';
|
|
9
|
+
export { createLinter, type IRunOptions, type Linter } from './runner.js';
|
|
10
|
+
export * from './types.js';
|
|
11
|
+
export { type IRulesetProblem, validateRuleset } from './validate-ruleset.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createDocument } from './document.js';
|
|
2
|
+
export { detectFormats } from './formats.js';
|
|
3
|
+
export { globToRegExp, matchesGlob } from './glob.js';
|
|
4
|
+
export { compileQuery, query, queryCompiled, queryMany } from './jsonpath.js';
|
|
5
|
+
export { lint, lintWithResult } from './lint.js';
|
|
6
|
+
export { runPlugins, } from './plugin.js';
|
|
7
|
+
export { pointerToPath, resolveSourceOrigin, resolveSourceOriginFromMap, resolveSourcePath, } from './pointers.js';
|
|
8
|
+
export { createRuleset, } from './ruleset.js';
|
|
9
|
+
export { createLinter } from './runner.js';
|
|
10
|
+
export * from './types.js';
|
|
11
|
+
export { validateRuleset } from './validate-ruleset.js';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { JsonPath } from './types.js';
|
|
2
|
+
/** A single JSONPath match: the matched value and its concrete path from the root. */
|
|
3
|
+
export type IQueryMatch = {
|
|
4
|
+
value: unknown;
|
|
5
|
+
path: JsonPath;
|
|
6
|
+
};
|
|
7
|
+
type FilterFn = (value: unknown, property: string | number | undefined, parent: unknown, root: unknown, path: JsonPath, parentProperty: string | number | undefined) => boolean;
|
|
8
|
+
type Selector = {
|
|
9
|
+
kind: 'child';
|
|
10
|
+
name: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'index';
|
|
13
|
+
index: number;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'wildcard';
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'union';
|
|
18
|
+
names: (string | number)[];
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'filter';
|
|
21
|
+
test: FilterFn;
|
|
22
|
+
source: string;
|
|
23
|
+
usesPath: boolean;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'parent';
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'keys';
|
|
28
|
+
};
|
|
29
|
+
/** One compiled segment of a path: a selector and whether it follows a `..` descent. */
|
|
30
|
+
export type Step = {
|
|
31
|
+
/** Whether this step is reached via `..` (descendant-or-self). */
|
|
32
|
+
recursive: boolean;
|
|
33
|
+
selector: Selector;
|
|
34
|
+
};
|
|
35
|
+
/** A JSONPath expression compiled into flat steps; the runner's query planner groups on these. */
|
|
36
|
+
export type CompiledPath = {
|
|
37
|
+
readonly expression: string;
|
|
38
|
+
readonly steps: Step[];
|
|
39
|
+
/** True when the path contains at least one `..` step. */
|
|
40
|
+
readonly hasDescent: boolean;
|
|
41
|
+
};
|
|
42
|
+
/** Compiles a JSONPath `expression` into a {@link CompiledPath}, cached by string so repeats are free. */
|
|
43
|
+
export declare const compileQuery: (expression: string) => CompiledPath;
|
|
44
|
+
/** Evaluates a pre-compiled path against `data`. */
|
|
45
|
+
export declare const queryCompiled: (data: unknown, compiled: CompiledPath) => IQueryMatch[];
|
|
46
|
+
/**
|
|
47
|
+
* Evaluates many pre-compiled paths against `data`, sharing a *single* recursive
|
|
48
|
+
* descent of the tree across every `$..`-rooted path. The ruleset has ~16
|
|
49
|
+
* descent `given`s; walking the (post-deref, ~60k-node) tree once and testing
|
|
50
|
+
* each path's first selector at every node — instead of one full traversal per
|
|
51
|
+
* path — is the dominant rule-run speedup on large specs. Non-recursive paths
|
|
52
|
+
* are evaluated directly (cheap, no descent).
|
|
53
|
+
*
|
|
54
|
+
* Returns one match array per input path, index-aligned with `compiled`.
|
|
55
|
+
*/
|
|
56
|
+
export declare const queryMany: (data: unknown, compiled: CompiledPath[]) => IQueryMatch[][];
|
|
57
|
+
/** Runs a JSONPath expression and returns each match with its concrete path. */
|
|
58
|
+
export declare const query: (data: unknown, expression: string) => IQueryMatch[];
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
2
|
+
// jsonpath-plus emits numeric array indices as strings and Linter historically
|
|
3
|
+
// normalized *any* all-digit segment (including object keys like "200") to a
|
|
4
|
+
// number. Replicate that exactly so source-map lookups are unchanged.
|
|
5
|
+
const normalizeSegment = (segment) => {
|
|
6
|
+
if (typeof segment === 'number')
|
|
7
|
+
return segment;
|
|
8
|
+
if (segment.length > 0 && /^\d+$/.test(segment))
|
|
9
|
+
return Number(segment);
|
|
10
|
+
return segment;
|
|
11
|
+
};
|
|
12
|
+
const normalizePath = (path) => path.map(normalizeSegment);
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Compilation
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
const compileCache = new Map();
|
|
17
|
+
const filterCache = new Map();
|
|
18
|
+
const compileFilter = (source) => {
|
|
19
|
+
const cached = filterCache.get(source);
|
|
20
|
+
if (cached)
|
|
21
|
+
return cached;
|
|
22
|
+
// Map jsonpath-plus' `@`-context tokens onto real identifiers, longest first
|
|
23
|
+
// so `@parentProperty` is not eaten by `@parent`.
|
|
24
|
+
const body = source
|
|
25
|
+
.replace(/@parentProperty/g, '_pp')
|
|
26
|
+
.replace(/@parent/g, '_parent')
|
|
27
|
+
.replace(/@property/g, '_prop')
|
|
28
|
+
.replace(/@path/g, '_path')
|
|
29
|
+
.replace(/@root/g, '_root')
|
|
30
|
+
.replace(/@/g, '_v');
|
|
31
|
+
let fn;
|
|
32
|
+
try {
|
|
33
|
+
// `_pp` (`@parentProperty`) is supplied directly by the caller rather than
|
|
34
|
+
// derived from a materialized path, so most filters never force a path
|
|
35
|
+
// allocation. `_path` (`@path`) is only materialized for filters that use it.
|
|
36
|
+
const compiled = new Function('_v', '_prop', '_parent', '_root', '_path', '_pp', `try { return !!(${body}); } catch (_e) { return false; }`);
|
|
37
|
+
fn = compiled;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
fn = () => false;
|
|
41
|
+
}
|
|
42
|
+
filterCache.set(source, fn);
|
|
43
|
+
return fn;
|
|
44
|
+
};
|
|
45
|
+
/** Splits bracket content on top-level commas, respecting quotes. */
|
|
46
|
+
const splitUnion = (content) => {
|
|
47
|
+
const parts = [];
|
|
48
|
+
let depth = 0;
|
|
49
|
+
let quote = '';
|
|
50
|
+
let current = '';
|
|
51
|
+
for (let i = 0; i < content.length; i++) {
|
|
52
|
+
const ch = content[i];
|
|
53
|
+
if (quote) {
|
|
54
|
+
if (ch === quote)
|
|
55
|
+
quote = '';
|
|
56
|
+
current += ch;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === '"' || ch === "'") {
|
|
60
|
+
quote = ch;
|
|
61
|
+
current += ch;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (ch === '[' || ch === '(')
|
|
65
|
+
depth++;
|
|
66
|
+
else if (ch === ']' || ch === ')')
|
|
67
|
+
depth--;
|
|
68
|
+
if (ch === ',' && depth === 0) {
|
|
69
|
+
parts.push(current);
|
|
70
|
+
current = '';
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
current += ch;
|
|
74
|
+
}
|
|
75
|
+
parts.push(current);
|
|
76
|
+
return parts;
|
|
77
|
+
};
|
|
78
|
+
const unquote = (token) => {
|
|
79
|
+
const t = token.trim();
|
|
80
|
+
if (t.length >= 2 && (t[0] === '"' || t[0] === "'") && t[t.length - 1] === t[0]) {
|
|
81
|
+
return t.slice(1, -1);
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
};
|
|
85
|
+
const bracketSelector = (content) => {
|
|
86
|
+
const trimmed = content.trim();
|
|
87
|
+
if (trimmed === '*')
|
|
88
|
+
return { kind: 'wildcard' };
|
|
89
|
+
if (trimmed.startsWith('?')) {
|
|
90
|
+
// `?(expr)` — extract the inner expression between the first `(` and last `)`.
|
|
91
|
+
const open = trimmed.indexOf('(');
|
|
92
|
+
const close = trimmed.lastIndexOf(')');
|
|
93
|
+
const expr = open !== -1 && close > open ? trimmed.slice(open + 1, close) : trimmed.slice(1);
|
|
94
|
+
return { kind: 'filter', test: compileFilter(expr), source: expr, usesPath: expr.includes('@path') };
|
|
95
|
+
}
|
|
96
|
+
const parts = splitUnion(content);
|
|
97
|
+
const names = [];
|
|
98
|
+
for (const part of parts) {
|
|
99
|
+
const token = part.trim();
|
|
100
|
+
const literal = unquote(token);
|
|
101
|
+
if (literal !== null) {
|
|
102
|
+
names.push(literal);
|
|
103
|
+
}
|
|
104
|
+
else if (/^-?\d+$/.test(token)) {
|
|
105
|
+
names.push(Number(token));
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
names.push(token);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (names.length === 1) {
|
|
112
|
+
const only = names[0];
|
|
113
|
+
return typeof only === 'number' ? { kind: 'index', index: only } : { kind: 'child', name: only };
|
|
114
|
+
}
|
|
115
|
+
return { kind: 'union', names };
|
|
116
|
+
};
|
|
117
|
+
/** Finds the index of the `]` that closes the `[` at `start`, respecting quotes/nesting. */
|
|
118
|
+
const findBracketEnd = (expression, start) => {
|
|
119
|
+
let depth = 0;
|
|
120
|
+
let quote = '';
|
|
121
|
+
for (let i = start; i < expression.length; i++) {
|
|
122
|
+
const ch = expression[i];
|
|
123
|
+
if (quote) {
|
|
124
|
+
if (ch === quote)
|
|
125
|
+
quote = '';
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (ch === '"' || ch === "'")
|
|
129
|
+
quote = ch;
|
|
130
|
+
else if (ch === '[' || ch === '(')
|
|
131
|
+
depth++;
|
|
132
|
+
else if (ch === ')')
|
|
133
|
+
depth--;
|
|
134
|
+
else if (ch === ']') {
|
|
135
|
+
depth--;
|
|
136
|
+
if (depth === 0)
|
|
137
|
+
return i;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return -1;
|
|
141
|
+
};
|
|
142
|
+
const readName = (expression, start) => {
|
|
143
|
+
let i = start;
|
|
144
|
+
while (i < expression.length && !'.[]^~'.includes(expression[i]))
|
|
145
|
+
i++;
|
|
146
|
+
return { name: expression.slice(start, i), end: i };
|
|
147
|
+
};
|
|
148
|
+
/** Compiles a JSONPath `expression` into a {@link CompiledPath}, cached by string so repeats are free. */
|
|
149
|
+
export const compileQuery = (expression) => {
|
|
150
|
+
const cached = compileCache.get(expression);
|
|
151
|
+
if (cached)
|
|
152
|
+
return cached;
|
|
153
|
+
const steps = [];
|
|
154
|
+
let hasDescent = false;
|
|
155
|
+
let i = 0;
|
|
156
|
+
if (expression[0] === '$')
|
|
157
|
+
i = 1;
|
|
158
|
+
let recursive = false;
|
|
159
|
+
while (i < expression.length) {
|
|
160
|
+
const ch = expression[i];
|
|
161
|
+
if (ch === '.') {
|
|
162
|
+
if (expression[i + 1] === '.') {
|
|
163
|
+
recursive = true;
|
|
164
|
+
hasDescent = true;
|
|
165
|
+
i += 2;
|
|
166
|
+
// A bare `..` followed by `.`/end is unusual; loop handles the selector.
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
i++;
|
|
170
|
+
if (expression[i] === '*') {
|
|
171
|
+
steps.push({ recursive, selector: { kind: 'wildcard' } });
|
|
172
|
+
recursive = false;
|
|
173
|
+
i++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const { name, end } = readName(expression, i);
|
|
177
|
+
steps.push({ recursive, selector: { kind: 'child', name } });
|
|
178
|
+
recursive = false;
|
|
179
|
+
i = end;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (ch === '[') {
|
|
183
|
+
const end = findBracketEnd(expression, i);
|
|
184
|
+
if (end === -1)
|
|
185
|
+
break;
|
|
186
|
+
const content = expression.slice(i + 1, end);
|
|
187
|
+
steps.push({ recursive, selector: bracketSelector(content) });
|
|
188
|
+
recursive = false;
|
|
189
|
+
i = end + 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (ch === '^') {
|
|
193
|
+
steps.push({ recursive: false, selector: { kind: 'parent' } });
|
|
194
|
+
i++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (ch === '~') {
|
|
198
|
+
steps.push({ recursive: false, selector: { kind: 'keys' } });
|
|
199
|
+
i++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (ch === '*') {
|
|
203
|
+
steps.push({ recursive, selector: { kind: 'wildcard' } });
|
|
204
|
+
recursive = false;
|
|
205
|
+
i++;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// Bare name following `..` (e.g. `$..foo`) or other unexpected token.
|
|
209
|
+
if (recursive) {
|
|
210
|
+
const { name, end } = readName(expression, i);
|
|
211
|
+
if (end > i) {
|
|
212
|
+
steps.push({ recursive: true, selector: { kind: 'child', name } });
|
|
213
|
+
recursive = false;
|
|
214
|
+
i = end;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
i++;
|
|
219
|
+
}
|
|
220
|
+
const compiled = { expression, steps, hasDescent };
|
|
221
|
+
compileCache.set(expression, compiled);
|
|
222
|
+
return compiled;
|
|
223
|
+
};
|
|
224
|
+
const EMPTY_PATH = [];
|
|
225
|
+
/** Materializes the concrete (un-normalized) path from root to `node`. */
|
|
226
|
+
const pathOf = (node) => {
|
|
227
|
+
let depth = 0;
|
|
228
|
+
for (let n = node; n !== undefined && n.parent !== undefined; n = n.parent)
|
|
229
|
+
depth++;
|
|
230
|
+
const out = new Array(depth);
|
|
231
|
+
let i = depth - 1;
|
|
232
|
+
for (let n = node; n !== undefined && n.parent !== undefined; n = n.parent) {
|
|
233
|
+
out[i--] = n.key;
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
};
|
|
237
|
+
const applySelector = (node, selector, root, out) => {
|
|
238
|
+
const value = node.value;
|
|
239
|
+
switch (selector.kind) {
|
|
240
|
+
case 'child': {
|
|
241
|
+
if (isObject(value)) {
|
|
242
|
+
if (Object.hasOwn(value, selector.name))
|
|
243
|
+
out.push({ value: value[selector.name], parent: node, key: selector.name });
|
|
244
|
+
}
|
|
245
|
+
else if (Array.isArray(value) && /^\d+$/.test(selector.name)) {
|
|
246
|
+
const idx = Number(selector.name);
|
|
247
|
+
if (idx < value.length)
|
|
248
|
+
out.push({ value: value[idx], parent: node, key: idx });
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
case 'index': {
|
|
253
|
+
if (Array.isArray(value)) {
|
|
254
|
+
const idx = selector.index < 0 ? value.length + selector.index : selector.index;
|
|
255
|
+
if (idx >= 0 && idx < value.length)
|
|
256
|
+
out.push({ value: value[idx], parent: node, key: idx });
|
|
257
|
+
}
|
|
258
|
+
else if (isObject(value) && Object.hasOwn(value, selector.index)) {
|
|
259
|
+
out.push({ value: value[selector.index], parent: node, key: selector.index });
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
case 'wildcard': {
|
|
264
|
+
if (Array.isArray(value)) {
|
|
265
|
+
for (let idx = 0; idx < value.length; idx++)
|
|
266
|
+
out.push({ value: value[idx], parent: node, key: idx });
|
|
267
|
+
}
|
|
268
|
+
else if (isObject(value)) {
|
|
269
|
+
for (const key of Object.keys(value))
|
|
270
|
+
out.push({ value: value[key], parent: node, key });
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
case 'union': {
|
|
275
|
+
for (const name of selector.names) {
|
|
276
|
+
if (Array.isArray(value)) {
|
|
277
|
+
if (typeof name === 'number') {
|
|
278
|
+
const idx = name < 0 ? value.length + name : name;
|
|
279
|
+
if (idx >= 0 && idx < value.length)
|
|
280
|
+
out.push({ value: value[idx], parent: node, key: idx });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else if (isObject(value) && Object.hasOwn(value, name)) {
|
|
284
|
+
out.push({ value: value[name], parent: node, key: name });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
case 'filter': {
|
|
290
|
+
// `@parentProperty` is `node.key`; `@path` is materialized only when used.
|
|
291
|
+
const pp = node.key;
|
|
292
|
+
if (Array.isArray(value)) {
|
|
293
|
+
for (let idx = 0; idx < value.length; idx++) {
|
|
294
|
+
const child = { value: value[idx], parent: node, key: idx };
|
|
295
|
+
const path = selector.usesPath ? pathOf(child) : EMPTY_PATH;
|
|
296
|
+
if (selector.test(value[idx], idx, value, root, path, pp))
|
|
297
|
+
out.push(child);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else if (isObject(value)) {
|
|
301
|
+
for (const key of Object.keys(value)) {
|
|
302
|
+
const child = { value: value[key], parent: node, key };
|
|
303
|
+
const path = selector.usesPath ? pathOf(child) : EMPTY_PATH;
|
|
304
|
+
if (selector.test(value[key], key, value, root, path, pp))
|
|
305
|
+
out.push(child);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
case 'parent': {
|
|
311
|
+
if (node.parent !== undefined)
|
|
312
|
+
out.push(node.parent);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
case 'keys': {
|
|
316
|
+
if (node.parent === undefined)
|
|
317
|
+
return;
|
|
318
|
+
// The selected value is the node's own key, but it occupies the same path.
|
|
319
|
+
out.push({ value: node.key, parent: node.parent, key: node.key });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
/** Visits `node` and every descendant (preorder), invoking `visit` on each. */
|
|
325
|
+
const walkDescendants = (node, visit) => {
|
|
326
|
+
visit(node);
|
|
327
|
+
const value = node.value;
|
|
328
|
+
if (Array.isArray(value)) {
|
|
329
|
+
for (let idx = 0; idx < value.length; idx++)
|
|
330
|
+
walkDescendants({ value: value[idx], parent: node, key: idx }, visit);
|
|
331
|
+
}
|
|
332
|
+
else if (isObject(value)) {
|
|
333
|
+
for (const key of Object.keys(value))
|
|
334
|
+
walkDescendants({ value: value[key], parent: node, key }, visit);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
/** Applies a list of steps to an existing set of nodes. */
|
|
338
|
+
const applySteps = (root, initial, steps) => {
|
|
339
|
+
let current = initial;
|
|
340
|
+
for (const step of steps) {
|
|
341
|
+
const next = [];
|
|
342
|
+
if (step.recursive) {
|
|
343
|
+
for (const node of current)
|
|
344
|
+
walkDescendants(node, (n) => applySelector(n, step.selector, root, next));
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
for (const node of current)
|
|
348
|
+
applySelector(node, step.selector, root, next);
|
|
349
|
+
}
|
|
350
|
+
current = next;
|
|
351
|
+
}
|
|
352
|
+
return current;
|
|
353
|
+
};
|
|
354
|
+
const runSteps = (root, steps) => applySteps(root, [{ value: root, parent: undefined, key: undefined }], steps);
|
|
355
|
+
const toMatches = (nodes) => {
|
|
356
|
+
const out = new Array(nodes.length);
|
|
357
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
358
|
+
const node = nodes[i];
|
|
359
|
+
out[i] = { value: node.value, path: normalizePath(pathOf(node)) };
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
};
|
|
363
|
+
/** Evaluates a pre-compiled path against `data`. */
|
|
364
|
+
export const queryCompiled = (data, compiled) => {
|
|
365
|
+
if (data === null || data === undefined)
|
|
366
|
+
return [];
|
|
367
|
+
return toMatches(runSteps(data, compiled.steps));
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* Evaluates many pre-compiled paths against `data`, sharing a *single* recursive
|
|
371
|
+
* descent of the tree across every `$..`-rooted path. The ruleset has ~16
|
|
372
|
+
* descent `given`s; walking the (post-deref, ~60k-node) tree once and testing
|
|
373
|
+
* each path's first selector at every node — instead of one full traversal per
|
|
374
|
+
* path — is the dominant rule-run speedup on large specs. Non-recursive paths
|
|
375
|
+
* are evaluated directly (cheap, no descent).
|
|
376
|
+
*
|
|
377
|
+
* Returns one match array per input path, index-aligned with `compiled`.
|
|
378
|
+
*/
|
|
379
|
+
export const queryMany = (data, compiled) => {
|
|
380
|
+
const out = new Array(compiled.length);
|
|
381
|
+
if (data === null || data === undefined) {
|
|
382
|
+
for (let i = 0; i < compiled.length; i++)
|
|
383
|
+
out[i] = [];
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
const recursive = [];
|
|
387
|
+
for (let i = 0; i < compiled.length; i++) {
|
|
388
|
+
const c = compiled[i];
|
|
389
|
+
const first = c.steps[0];
|
|
390
|
+
if (first?.recursive)
|
|
391
|
+
recursive.push(i);
|
|
392
|
+
else
|
|
393
|
+
out[i] = queryCompiled(data, c);
|
|
394
|
+
}
|
|
395
|
+
if (recursive.length > 0) {
|
|
396
|
+
// Walk the whole tree exactly once, applying every descent path's first
|
|
397
|
+
// selector at each visited node in the same pass (fused descent), so the
|
|
398
|
+
// ~15k-node resolved tree is traversed a single time rather than once per
|
|
399
|
+
// `$..` given. Each path's surviving seeds then run its remaining steps.
|
|
400
|
+
const firsts = recursive.map((i) => compiled[i].steps[0]);
|
|
401
|
+
const seeds = recursive.map(() => []);
|
|
402
|
+
const root = { value: data, parent: undefined, key: undefined };
|
|
403
|
+
walkDescendants(root, (node) => {
|
|
404
|
+
for (let r = 0; r < recursive.length; r++)
|
|
405
|
+
applySelector(node, firsts[r].selector, data, seeds[r]);
|
|
406
|
+
});
|
|
407
|
+
for (let r = 0; r < recursive.length; r++) {
|
|
408
|
+
const c = compiled[recursive[r]];
|
|
409
|
+
out[recursive[r]] = toMatches(applySteps(data, seeds[r], c.steps.slice(1)));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
};
|
|
414
|
+
/** Runs a JSONPath expression and returns each match with its concrete path. */
|
|
415
|
+
export const query = (data, expression) => queryCompiled(data, compileQuery(expression));
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type Document, type IDocumentOptions } from './document.js';
|
|
2
|
+
import type { LintPlugin } from './plugin.js';
|
|
3
|
+
import type { Ruleset } from './ruleset.js';
|
|
4
|
+
import { type IDiagnostic, type ISourceSet } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* How a resolved (`$ref`-dereferenced) tree is produced from a parsed document.
|
|
7
|
+
* The engine stays free of any `$ref` resolver: a caller may inject one (for
|
|
8
|
+
* example wrapping `@amritk/resolve-refs`). Returning no `sources` means
|
|
9
|
+
* findings map back to the root document only.
|
|
10
|
+
*/
|
|
11
|
+
export type LintResolver = (document: Document, context: {
|
|
12
|
+
input: string;
|
|
13
|
+
}) => {
|
|
14
|
+
resolved: unknown;
|
|
15
|
+
sources?: ISourceSet;
|
|
16
|
+
} | Promise<{
|
|
17
|
+
resolved: unknown;
|
|
18
|
+
sources?: ISourceSet;
|
|
19
|
+
}>;
|
|
20
|
+
/** Options for {@link lint} / {@link lintWithResult}. */
|
|
21
|
+
export type LintOptions = IDocumentOptions & {
|
|
22
|
+
/** A normalized ruleset to evaluate (built via {@link createRuleset}). */
|
|
23
|
+
ruleset: Ruleset;
|
|
24
|
+
/**
|
|
25
|
+
* Produces the resolved tree for rules with `resolved: true`. When omitted the
|
|
26
|
+
* raw parsed data is used as-is (no `$ref` dereferencing).
|
|
27
|
+
*/
|
|
28
|
+
resolve?: LintResolver;
|
|
29
|
+
/**
|
|
30
|
+
* Checked against the parsed data before any resolution: when it returns true
|
|
31
|
+
* the document is skipped and produces no findings. Presets use this to skip
|
|
32
|
+
* documents of an unrecognized format without paying for `$ref` resolution.
|
|
33
|
+
*/
|
|
34
|
+
skip?: (data: unknown) => boolean;
|
|
35
|
+
/** Plugins run after the rule pass via {@link runPlugins} (e.g. auto-fix). */
|
|
36
|
+
plugins?: LintPlugin[];
|
|
37
|
+
};
|
|
38
|
+
/** The full result of a {@link lintWithResult} run: findings plus plugin output. */
|
|
39
|
+
export type LintResult = {
|
|
40
|
+
diagnostics: IDiagnostic[];
|
|
41
|
+
/** A rewritten document, when a plugin (e.g. auto-fix) produced one. */
|
|
42
|
+
output?: string;
|
|
43
|
+
/** Per-plugin structured output, keyed by plugin name. */
|
|
44
|
+
pluginData: Record<string, unknown>;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Lints `input` against a normalized `ruleset`, returning the full
|
|
48
|
+
* {@link LintResult} (findings plus anything the configured `plugins` produced).
|
|
49
|
+
* This is the format-agnostic pipeline — parse with source maps → resolve (via
|
|
50
|
+
* the injected {@link LintResolver}) → run the ruleset → run plugins. A higher
|
|
51
|
+
* level entry point (such as `@amritk/lint`) supplies the ruleset, and
|
|
52
|
+
* optionally a resolver and format-skip predicate; {@link lint} is a thin
|
|
53
|
+
* wrapper returning just the diagnostics.
|
|
54
|
+
*/
|
|
55
|
+
export declare const lintWithResult: (input: string, options: LintOptions) => Promise<LintResult>;
|
|
56
|
+
/** Lints `input` against a normalized `ruleset` and returns just the findings. */
|
|
57
|
+
export declare const lint: (input: string, options: LintOptions) => Promise<IDiagnostic[]>;
|