@amritk/lint 0.0.0 → 0.2.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.
Files changed (136) hide show
  1. package/README.md +34 -1
  2. package/dist/core/document.d.ts +19 -0
  3. package/dist/core/document.js +11 -0
  4. package/dist/core/formats.d.ts +9 -0
  5. package/dist/core/formats.js +14 -0
  6. package/dist/core/glob.d.ts +4 -0
  7. package/dist/core/glob.js +48 -0
  8. package/dist/core/index.d.ts +11 -0
  9. package/dist/core/index.js +11 -0
  10. package/dist/core/jsonpath.d.ts +59 -0
  11. package/dist/core/jsonpath.js +415 -0
  12. package/dist/core/lint.d.ts +57 -0
  13. package/dist/core/lint.js +82 -0
  14. package/dist/core/plugin.d.ts +59 -0
  15. package/dist/core/plugin.js +25 -0
  16. package/dist/core/pointers.d.ts +35 -0
  17. package/dist/core/pointers.js +169 -0
  18. package/dist/core/ruleset.d.ts +52 -0
  19. package/dist/core/ruleset.js +170 -0
  20. package/dist/core/runner.d.ts +21 -0
  21. package/dist/core/runner.js +222 -0
  22. package/dist/core/types.d.ts +170 -0
  23. package/dist/core/types.js +1 -0
  24. package/dist/core/validate-ruleset.d.ts +14 -0
  25. package/dist/core/validate-ruleset.js +105 -0
  26. package/dist/fix/apply.d.ts +21 -0
  27. package/dist/fix/apply.js +51 -0
  28. package/dist/fix/index.d.ts +3 -0
  29. package/dist/fix/index.js +2 -0
  30. package/dist/fix/plugin.d.ts +18 -0
  31. package/dist/fix/plugin.js +21 -0
  32. package/dist/fix/types.d.ts +39 -0
  33. package/dist/fix/types.js +0 -0
  34. package/dist/functions/alphabetical.d.ts +8 -0
  35. package/dist/functions/alphabetical.js +28 -0
  36. package/dist/functions/casing.d.ts +14 -0
  37. package/dist/functions/casing.js +30 -0
  38. package/dist/functions/defined.d.ts +3 -0
  39. package/dist/functions/defined.js +6 -0
  40. package/dist/functions/enumeration.d.ts +5 -0
  41. package/dist/functions/enumeration.js +10 -0
  42. package/dist/functions/falsy.d.ts +3 -0
  43. package/dist/functions/falsy.js +6 -0
  44. package/dist/functions/index.d.ts +16 -0
  45. package/dist/functions/index.js +42 -0
  46. package/dist/functions/length.d.ts +6 -0
  47. package/dist/functions/length.js +27 -0
  48. package/dist/functions/pattern.d.ts +6 -0
  49. package/dist/functions/pattern.js +20 -0
  50. package/dist/functions/schema.d.ts +8 -0
  51. package/dist/functions/schema.js +36 -0
  52. package/dist/functions/truthy.d.ts +3 -0
  53. package/dist/functions/truthy.js +6 -0
  54. package/dist/functions/typed-enum.d.ts +3 -0
  55. package/dist/functions/typed-enum.js +34 -0
  56. package/dist/functions/undefined.d.ts +6 -0
  57. package/dist/functions/undefined.js +9 -0
  58. package/dist/functions/unreferenced-reusable-object.d.ts +8 -0
  59. package/dist/functions/unreferenced-reusable-object.js +37 -0
  60. package/dist/functions/xor.d.ts +7 -0
  61. package/dist/functions/xor.js +11 -0
  62. package/dist/index.d.ts +89 -0
  63. package/dist/index.js +168 -0
  64. package/dist/parsers/edit-model.d.ts +69 -0
  65. package/dist/parsers/edit-model.js +326 -0
  66. package/dist/parsers/index.d.ts +18 -0
  67. package/dist/parsers/index.js +21 -0
  68. package/dist/parsers/json.d.ts +3 -0
  69. package/dist/parsers/json.js +38 -0
  70. package/dist/parsers/lines.d.ts +13 -0
  71. package/dist/parsers/lines.js +28 -0
  72. package/dist/parsers/types.d.ts +50 -0
  73. package/dist/parsers/types.js +8 -0
  74. package/dist/parsers/yaml.d.ts +6 -0
  75. package/dist/parsers/yaml.js +65 -0
  76. package/dist/rules/openapi/fixers.d.ts +7 -0
  77. package/dist/rules/openapi/fixers.js +189 -0
  78. package/dist/rules/openapi/formats.d.ts +13 -0
  79. package/dist/rules/openapi/formats.js +23 -0
  80. package/dist/rules/openapi/functions/example-validation.d.ts +5 -0
  81. package/dist/rules/openapi/functions/example-validation.js +97 -0
  82. package/dist/rules/openapi/functions/helpers.d.ts +3 -0
  83. package/dist/rules/openapi/functions/helpers.js +5 -0
  84. package/dist/rules/openapi/functions/index.d.ts +24 -0
  85. package/dist/rules/openapi/functions/index.js +67 -0
  86. package/dist/rules/openapi/functions/oas-additional-operations.d.ts +8 -0
  87. package/dist/rules/openapi/functions/oas-additional-operations.js +25 -0
  88. package/dist/rules/openapi/functions/oas-discriminator.d.ts +3 -0
  89. package/dist/rules/openapi/functions/oas-discriminator.js +23 -0
  90. package/dist/rules/openapi/functions/oas-example-value.d.ts +3 -0
  91. package/dist/rules/openapi/functions/oas-example-value.js +31 -0
  92. package/dist/rules/openapi/functions/oas-mutually-exclusive.d.ts +10 -0
  93. package/dist/rules/openapi/functions/oas-mutually-exclusive.js +20 -0
  94. package/dist/rules/openapi/functions/oas-op-form-data-consume-check.d.ts +3 -0
  95. package/dist/rules/openapi/functions/oas-op-form-data-consume-check.js +21 -0
  96. package/dist/rules/openapi/functions/oas-op-id-unique.d.ts +3 -0
  97. package/dist/rules/openapi/functions/oas-op-id-unique.js +27 -0
  98. package/dist/rules/openapi/functions/oas-op-params.d.ts +3 -0
  99. package/dist/rules/openapi/functions/oas-op-params.js +21 -0
  100. package/dist/rules/openapi/functions/oas-op-security-defined.d.ts +5 -0
  101. package/dist/rules/openapi/functions/oas-op-security-defined.js +44 -0
  102. package/dist/rules/openapi/functions/oas-op-success-response.d.ts +3 -0
  103. package/dist/rules/openapi/functions/oas-op-success-response.js +11 -0
  104. package/dist/rules/openapi/functions/oas-path-param.d.ts +3 -0
  105. package/dist/rules/openapi/functions/oas-path-param.js +45 -0
  106. package/dist/rules/openapi/functions/oas-schema-example-deprecated.d.ts +10 -0
  107. package/dist/rules/openapi/functions/oas-schema-example-deprecated.js +42 -0
  108. package/dist/rules/openapi/functions/oas-schema.d.ts +15 -0
  109. package/dist/rules/openapi/functions/oas-schema.js +15 -0
  110. package/dist/rules/openapi/functions/oas-server-name-unique.d.ts +3 -0
  111. package/dist/rules/openapi/functions/oas-server-name-unique.js +20 -0
  112. package/dist/rules/openapi/functions/oas-server-variables.d.ts +3 -0
  113. package/dist/rules/openapi/functions/oas-server-variables.js +23 -0
  114. package/dist/rules/openapi/functions/oas-tag-defined.d.ts +3 -0
  115. package/dist/rules/openapi/functions/oas-tag-defined.js +21 -0
  116. package/dist/rules/openapi/functions/oas-tag-kind.d.ts +3 -0
  117. package/dist/rules/openapi/functions/oas-tag-kind.js +17 -0
  118. package/dist/rules/openapi/functions/oas-tag-parent-defined.d.ts +7 -0
  119. package/dist/rules/openapi/functions/oas-tag-parent-defined.js +45 -0
  120. package/dist/rules/openapi/functions/oas-tags-unique.d.ts +3 -0
  121. package/dist/rules/openapi/functions/oas-tags-unique.js +17 -0
  122. package/dist/rules/openapi/functions/oas-unused-component.d.ts +7 -0
  123. package/dist/rules/openapi/functions/oas-unused-component.js +52 -0
  124. package/dist/rules/openapi/functions/ref-siblings.d.ts +3 -0
  125. package/dist/rules/openapi/functions/ref-siblings.js +13 -0
  126. package/dist/rules/openapi/index.d.ts +25 -0
  127. package/dist/rules/openapi/index.js +127 -0
  128. package/dist/rules/openapi/oas.d.ts +3 -0
  129. package/dist/rules/openapi/oas.js +492 -0
  130. package/dist/rules/openapi/schemas/index.d.ts +8 -0
  131. package/dist/rules/openapi/schemas/index.js +36 -0
  132. package/dist/rules/openapi/schemas/oas20.json +1592 -0
  133. package/dist/rules/openapi/schemas/oas30.json +1651 -0
  134. package/dist/rules/openapi/schemas/oas31.json +1412 -0
  135. package/dist/rules/openapi/schemas/oas32.json +1684 -0
  136. package/package.json +8 -5
package/dist/index.js ADDED
@@ -0,0 +1,168 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, isAbsolute, resolve as resolvePath } from 'node:path';
4
+ import { createRuleset as createCoreRuleset, lintWithResult, } from './core/index.js';
5
+ import { createFixPlugin, FIX_PLUGIN_NAME } from './fix/index.js';
6
+ import { builtinFunctions } from './functions/index.js';
7
+ import { parseWithPointers } from './parsers/index.js';
8
+ // Re-export the engine, built-in functions, and fix subsystem as the package's
9
+ // public API. `export *` from `./core` also provides a low-level `createRuleset`,
10
+ // but the higher-level wrapper defined below (which layers in the built-in
11
+ // functions and file/package `extends` resolution) is the local export and wins.
12
+ // Rendering findings is a consumer concern: `lintDocument` returns structured
13
+ // `IDiagnostic[]`, and the caller decides how to display or serialize them.
14
+ export * from './core/index.js';
15
+ export * from './fix/index.js';
16
+ export * from './functions/index.js';
17
+ export { detectFormat, parseWithPointers } from './parsers/index.js';
18
+ const require = createRequire(import.meta.url);
19
+ /** Loads a ruleset definition from a file path by extension (YAML/JSON parsed, JS/CJS/MJS required). */
20
+ const loadRulesetFile = (file) => {
21
+ if (/\.(ya?ml|json)$/i.test(file)) {
22
+ return parseWithPointers(readFileSync(file, 'utf8')).data;
23
+ }
24
+ const module = require(file);
25
+ return (module.default ?? module);
26
+ };
27
+ /**
28
+ * Resolves an `extends` reference to a ruleset definition. Supports:
29
+ * - local file paths (relative to `basePath`, or absolute): `.yaml` / `.yml` / `.json` / `.js`,
30
+ * - npm package specifiers (resolved from `basePath`), including subpaths.
31
+ *
32
+ * The engine ships no named built-in rulesets, so every string `extends` target
33
+ * is a file path or an npm package.
34
+ */
35
+ export const resolveNamedRuleset = (name, basePath = process.cwd()) => {
36
+ if (name.startsWith('.') || isAbsolute(name)) {
37
+ const file = resolvePath(basePath, name);
38
+ return { definition: loadRulesetFile(file), basePath: dirname(file) };
39
+ }
40
+ let file;
41
+ try {
42
+ file = require.resolve(name, { paths: [basePath] });
43
+ }
44
+ catch {
45
+ throw new Error(`Cannot resolve extended ruleset "${name}" from ${basePath}`);
46
+ }
47
+ return { definition: loadRulesetFile(file), basePath: dirname(file) };
48
+ };
49
+ /** Loads a single custom function module (`<dir>/<name>.{js,cjs,mjs}` or a bare path). */
50
+ const loadFunctionByName = (basePath, dir, name) => {
51
+ const baseFile = resolvePath(basePath, dir, name);
52
+ for (const candidate of [baseFile, `${baseFile}.js`, `${baseFile}.cjs`, `${baseFile}.mjs`]) {
53
+ try {
54
+ const resolvedFile = require.resolve(candidate);
55
+ const module = require(resolvedFile);
56
+ const fn = module.default ?? module;
57
+ if (typeof fn !== 'function')
58
+ throw new Error(`"${name}" did not export a function`);
59
+ return fn;
60
+ }
61
+ catch (error) {
62
+ if (error.code !== 'MODULE_NOT_FOUND')
63
+ throw error;
64
+ }
65
+ }
66
+ throw new Error(`Cannot resolve custom function "${name}" from ${resolvePath(basePath, dir)}`);
67
+ };
68
+ /**
69
+ * Walks a ruleset definition (and its string `extends`) collecting custom
70
+ * functions declared via `functions` / `functionsDir`, each loaded relative to
71
+ * the directory of the ruleset that declared it. YAML/JSON rulesets reference
72
+ * functions by name; JS rulesets can instead pass direct references in `then`.
73
+ */
74
+ const collectCustomFunctions = (definition, basePath, into, seen) => {
75
+ if (seen.has(definition))
76
+ return;
77
+ seen.add(definition);
78
+ if (definition.extends) {
79
+ const entries = Array.isArray(definition.extends) ? definition.extends : [definition.extends];
80
+ for (const entry of entries) {
81
+ const target = Array.isArray(entry) ? entry[0] : entry;
82
+ if (typeof target === 'string') {
83
+ const resolved = resolveNamedRuleset(target, basePath);
84
+ collectCustomFunctions(resolved.definition, resolved.basePath, into, seen);
85
+ }
86
+ else {
87
+ collectCustomFunctions(target, basePath, into, seen);
88
+ }
89
+ }
90
+ }
91
+ if (Array.isArray(definition.functions)) {
92
+ const dir = definition.functionsDir ?? 'functions';
93
+ for (const name of definition.functions)
94
+ into[name] = loadFunctionByName(basePath, dir, name);
95
+ }
96
+ };
97
+ /**
98
+ * Builds a runnable {@link Ruleset} from a ruleset definition, layering the
99
+ * built-in functions (plus any custom ones the definition declares via
100
+ * `functions` / `functionsDir`) over the core engine and wiring up `extends`
101
+ * resolution against files and npm packages. With no definition it produces an
102
+ * empty ruleset (no rules run).
103
+ */
104
+ export const createRuleset = (definition, basePath) => {
105
+ const resolved = definition ?? {};
106
+ // Custom functions referenced by name (YAML/JSON rulesets) are loaded relative
107
+ // to the declaring ruleset's directory and layered over the built-ins.
108
+ let functions = builtinFunctions;
109
+ const custom = {};
110
+ collectCustomFunctions(resolved, basePath ?? process.cwd(), custom, new Set());
111
+ if (Object.keys(custom).length > 0)
112
+ functions = { ...builtinFunctions, ...custom };
113
+ return createCoreRuleset(resolved, {
114
+ functions,
115
+ resolve: resolveNamedRuleset,
116
+ ...(basePath !== undefined ? { basePath } : {}),
117
+ });
118
+ };
119
+ /**
120
+ * Lints a JSON/YAML `input` end to end: parses with source maps and applies the
121
+ * ruleset. Returns just the findings; use {@link lintDocumentWithResult} for the
122
+ * full result.
123
+ */
124
+ export const lintDocument = async (input, options = {}) => (await lintDocumentWithResult(input, options)).diagnostics;
125
+ /**
126
+ * Like {@link lintDocument}, but returns the full {@link ILintResult} — including
127
+ * anything the configured `plugins` produced (e.g. the auto-fix plugin's
128
+ * rewritten `output`).
129
+ */
130
+ export const lintDocumentWithResult = async (input, options = {}) => {
131
+ const { ruleset: rulesetDefinition, rulesetBasePath, resolve, plugins, ...documentOptions } = options;
132
+ const ruleset = createRuleset(rulesetDefinition, rulesetBasePath);
133
+ return lintWithResult(input, {
134
+ ...documentOptions,
135
+ ruleset,
136
+ ...(resolve ? { resolve } : {}),
137
+ ...(plugins ? { plugins } : {}),
138
+ });
139
+ };
140
+ // One fix pass can unblock the next, so we lint-and-fix to a fixpoint. The cap is
141
+ // a safety net against a fixer that oscillates rather than converging — in
142
+ // practice a couple of passes is plenty.
143
+ const MAX_FIX_PASSES = 10;
144
+ /**
145
+ * Lints a document and applies the supplied `fixers` repeatedly until the
146
+ * document stops changing (or {@link MAX_FIX_PASSES} is reached), then re-lints
147
+ * so `remaining` reflects the fixed document. A one-call convenience over
148
+ * {@link lintDocumentWithResult} + `createFixPlugin`. With no `fixers` this is a
149
+ * no-op that just returns the findings.
150
+ */
151
+ export const fixDocument = async (input, options = {}) => {
152
+ const { fixers = {}, safeOnly, ...lintOptions } = options;
153
+ const plugin = createFixPlugin(fixers, { safeOnly: safeOnly !== false });
154
+ let current = input;
155
+ const applied = [];
156
+ for (let pass = 0; pass < MAX_FIX_PASSES; pass++) {
157
+ const result = await lintDocumentWithResult(current, { ...lintOptions, plugins: [plugin] });
158
+ // No rewrite, or a rewrite that matches what we already have, means we have converged.
159
+ if (result.output === undefined || result.output === current)
160
+ break;
161
+ current = result.output;
162
+ const data = result.pluginData[FIX_PLUGIN_NAME];
163
+ if (data)
164
+ applied.push(...data.applied);
165
+ }
166
+ const remaining = await lintDocument(current, lintOptions);
167
+ return { output: current, fixed: applied.length > 0, remaining, applied };
168
+ };
@@ -0,0 +1,69 @@
1
+ import type { ParserFormat } from './index.js';
2
+ import type { JsonPath } from './types.js';
3
+ /**
4
+ * A single structural edit against a parsed document, expressed in terms of JSON
5
+ * paths and values rather than raw character offsets. {@link applyEditOps} lowers
6
+ * each op to a minimal text edit so that untouched parts of the source — including
7
+ * comments, key order, and quoting — keep their original formatting.
8
+ */
9
+ export type EditOp =
10
+ /** Replace the scalar value at `path` (the existing quoting style is preserved). */
11
+ {
12
+ op: 'setValue';
13
+ path: JsonPath;
14
+ value: unknown;
15
+ }
16
+ /** Remove the object property at `path` (the last segment is the key). */
17
+ | {
18
+ op: 'removeProperty';
19
+ path: JsonPath;
20
+ }
21
+ /** Rename the object property at `path` (the last segment is the current key). */
22
+ | {
23
+ op: 'renameProperty';
24
+ path: JsonPath;
25
+ newKey: string;
26
+ }
27
+ /** Remove the listed array indices from the array at `path`. */
28
+ | {
29
+ op: 'removeItems';
30
+ path: JsonPath;
31
+ indices: number[];
32
+ }
33
+ /** Reorder the array at `path`; `order` lists the original indices in their new order. */
34
+ | {
35
+ op: 'reorderArray';
36
+ path: JsonPath;
37
+ order: number[];
38
+ }
39
+ /** Add a `key: value` property to the object at `path` (a no-op when the key already exists). */
40
+ | {
41
+ op: 'insertProperty';
42
+ path: JsonPath;
43
+ key: string;
44
+ value: unknown;
45
+ }
46
+ /** Insert `value` into the array at `path` at `index` (appended when `index` is omitted). */
47
+ | {
48
+ op: 'insertItem';
49
+ path: JsonPath;
50
+ value: unknown;
51
+ index?: number;
52
+ };
53
+ /** The text after applying a batch of edits, plus which ops actually changed it. */
54
+ export type ApplyEditOpsResult = {
55
+ output: string;
56
+ /** `changed[i]` is whether `ops[i]` altered the text (false when its path no longer resolved). */
57
+ changed: boolean[];
58
+ };
59
+ /**
60
+ * Applies `ops` to `source`, reporting both the edited text and, per op, whether
61
+ * it actually changed anything. Each op is lowered to a minimal text edit and
62
+ * applied sequentially (re-parsing between ops so offsets stay valid), preserving
63
+ * the formatting of everything it does not touch. An op whose target path no
64
+ * longer resolves is a no-op rather than an error — callers use `changed` to tell
65
+ * a real fix from one that quietly dropped out (e.g. its node was already gone).
66
+ */
67
+ export declare const applyEditOpsWithChanges: (source: string, format: ParserFormat, ops: EditOp[]) => ApplyEditOpsResult;
68
+ /** Applies `ops` to `source` and returns just the edited text. See {@link applyEditOpsWithChanges}. */
69
+ export declare const applyEditOps: (source: string, format: ParserFormat, ops: EditOp[]) => string;
@@ -0,0 +1,326 @@
1
+ import { isMap, isScalar, isSeq, parseDocument } from '@amritk/yaml';
2
+ import { applyEdits, findNodeAtLocation, getNodeValue, modify, parseTree } from 'jsonc-parser';
3
+ const splice = (text, start, end, replacement) => text.slice(0, start) + replacement + text.slice(end);
4
+ /** Expands `[start, end)` to cover whole lines: back to the line start, forward past the trailing newline. */
5
+ const expandLine = (text, start, end) => {
6
+ let s = start;
7
+ while (s > 0 && text.charCodeAt(s - 1) !== 10)
8
+ s--;
9
+ let e = end;
10
+ while (e < text.length && text.charCodeAt(e) !== 10)
11
+ e++;
12
+ if (e < text.length)
13
+ e++;
14
+ return [s, e];
15
+ };
16
+ // --- YAML ------------------------------------------------------------------
17
+ const keyName = (key) => (isScalar(key) ? String(key.value) : String(key));
18
+ /** Navigates the YAML CST to the node at `path`, or `undefined` if absent. */
19
+ const yamlNodeAt = (root, path) => {
20
+ let current = root ?? undefined;
21
+ for (const segment of path) {
22
+ if (current === undefined)
23
+ return undefined;
24
+ if (isMap(current)) {
25
+ const target = String(segment);
26
+ const pair = current.items.find((item) => keyName(item.key) === target);
27
+ current = pair?.value ?? undefined;
28
+ }
29
+ else if (isSeq(current)) {
30
+ current = current.items[Number(segment)];
31
+ }
32
+ else {
33
+ return undefined;
34
+ }
35
+ }
36
+ return current;
37
+ };
38
+ /** Resolves the key/value CST nodes of the property at `path` (last segment = key). */
39
+ const yamlPairAt = (root, path) => {
40
+ const parent = yamlNodeAt(root, path.slice(0, -1));
41
+ if (!parent || !isMap(parent))
42
+ return {};
43
+ const last = String(path[path.length - 1]);
44
+ const pair = parent.items.find((item) => keyName(item.key) === last);
45
+ if (!pair)
46
+ return {};
47
+ return { key: pair.key, ...(pair.value ? { value: pair.value } : {}) };
48
+ };
49
+ /** Serializes a scalar, preserving the quoting style of the value it replaces. */
50
+ const yamlScalar = (value, original) => {
51
+ if (typeof value !== 'string')
52
+ return value === null ? 'null' : String(value);
53
+ const quote = original.charCodeAt(0);
54
+ if (quote === 34 /* " */)
55
+ return JSON.stringify(value);
56
+ if (quote === 39 /* ' */)
57
+ return `'${value.replace(/'/g, "''")}'`;
58
+ return value;
59
+ };
60
+ const yamlKey = (key, original) => {
61
+ const quote = original.charCodeAt(0);
62
+ if (quote === 34)
63
+ return JSON.stringify(key);
64
+ if (quote === 39)
65
+ return `'${key.replace(/'/g, "''")}'`;
66
+ return key;
67
+ };
68
+ // Keys made only of these characters are safe to write bare in YAML; anything
69
+ // else (spaces, colons, flow indicators) gets double-quoted to stay valid.
70
+ const SAFE_YAML_KEY = /^[\w./-]+$/;
71
+ /** Serializes a key for an inserted property, quoting it only when it needs to be. */
72
+ const yamlInsertKey = (key) => (SAFE_YAML_KEY.test(key) ? key : JSON.stringify(key));
73
+ /**
74
+ * Serializes a value for insertion. YAML is a JSON superset, so JSON output is
75
+ * always valid YAML: scalars become bare literals (`42`, `true`, `"text"`) and
76
+ * objects/arrays become inline flow (`{"a":1}`). That keeps the inserted node on
77
+ * one line without us having to re-implement a block serializer.
78
+ */
79
+ const yamlInsertValue = (value) => JSON.stringify(value) ?? 'null';
80
+ /** Returns the leading whitespace of the line containing `offset` (its indentation). */
81
+ const lineIndent = (text, offset) => {
82
+ let start = offset;
83
+ while (start > 0 && text.charCodeAt(start - 1) !== 10)
84
+ start--;
85
+ let end = start;
86
+ while (end < text.length && (text.charCodeAt(end) === 32 || text.charCodeAt(end) === 9))
87
+ end++;
88
+ return text.slice(start, end);
89
+ };
90
+ const applyYamlOp = (text, op) => {
91
+ const root = parseDocument(text).contents;
92
+ switch (op.op) {
93
+ case 'setValue': {
94
+ const node = yamlNodeAt(root, op.path);
95
+ if (!node)
96
+ return text;
97
+ // Scalars keep the node's original quoting; objects/arrays are written as
98
+ // inline flow JSON (valid YAML) so a scalar can be widened to a collection.
99
+ const replacement = op.value !== null && typeof op.value === 'object'
100
+ ? yamlInsertValue(op.value)
101
+ : yamlScalar(op.value, text.slice(node.start, node.end));
102
+ return splice(text, node.start, node.end, replacement);
103
+ }
104
+ case 'renameProperty': {
105
+ const { key } = yamlPairAt(root, op.path);
106
+ if (!key)
107
+ return text;
108
+ return splice(text, key.start, key.end, yamlKey(op.newKey, text.slice(key.start, key.end)));
109
+ }
110
+ case 'removeProperty': {
111
+ const parent = yamlNodeAt(root, op.path.slice(0, -1));
112
+ if (!parent || !isMap(parent))
113
+ return text;
114
+ const last = String(op.path[op.path.length - 1]);
115
+ const index = parent.items.findIndex((item) => keyName(item.key) === last);
116
+ if (index === -1)
117
+ return text;
118
+ const pair = parent.items[index];
119
+ const key = pair?.key;
120
+ const value = pair?.value;
121
+ // Flow map (`{ a: 1, b: 2 }`): excise just this member plus one adjoining
122
+ // comma, keeping the braces and the surviving members on their shared line.
123
+ if (text.charCodeAt(parent.start) === 123 /* { */) {
124
+ if (parent.items.length === 1)
125
+ return splice(text, parent.start + 1, parent.end - 1, '');
126
+ // Not the last member: take the comma and gap up to the next key.
127
+ if (index < parent.items.length - 1) {
128
+ const next = parent.items[index + 1]?.key;
129
+ return splice(text, key.start, next.start, '');
130
+ }
131
+ // Last member: take the trailing comma and gap left by the previous one.
132
+ const prev = parent.items[index - 1];
133
+ const prevEnd = (prev?.value ?? prev?.key).end;
134
+ return splice(text, prevEnd, (value ?? key).end, '');
135
+ }
136
+ // Block map: drop the whole line(s) the property occupies.
137
+ const [start, end] = expandLine(text, key.start, value ? value.end : key.end);
138
+ return splice(text, start, end, '');
139
+ }
140
+ case 'removeItems': {
141
+ const seq = yamlNodeAt(root, op.path);
142
+ if (!seq || !isSeq(seq))
143
+ return text;
144
+ const removed = new Set(op.indices);
145
+ return rewriteYamlSeq(text, seq, seq.items.filter((_, index) => !removed.has(index)));
146
+ }
147
+ case 'reorderArray': {
148
+ const seq = yamlNodeAt(root, op.path);
149
+ if (!seq || !isSeq(seq) || seq.items.length === 0)
150
+ return text;
151
+ const reordered = op.order.map((index) => seq.items[index]).filter((item) => item != null);
152
+ return rewriteYamlSeq(text, seq, reordered);
153
+ }
154
+ case 'insertProperty': {
155
+ const parent = yamlNodeAt(root, op.path);
156
+ if (!parent || !isMap(parent))
157
+ return text;
158
+ // Inserting is additive only; if the key is already there we leave it alone.
159
+ if (parent.items.some((item) => keyName(item.key) === op.key))
160
+ return text;
161
+ const pair = `${yamlInsertKey(op.key)}: ${yamlInsertValue(op.value)}`;
162
+ if (text.charCodeAt(parent.start) === 123 /* { */) {
163
+ const last = parent.items[parent.items.length - 1];
164
+ // Append right after the last entry (keeping the brace's own spacing); an
165
+ // empty `{}` gets the pair tucked just inside the opening brace.
166
+ if (last) {
167
+ const lastEnd = (last.value ?? last.key).end;
168
+ return splice(text, lastEnd, lastEnd, `, ${pair}`);
169
+ }
170
+ return splice(text, parent.start + 1, parent.start + 1, pair);
171
+ }
172
+ // Block map: mirror the existing keys' indentation and add a line after the last one.
173
+ const last = parent.items[parent.items.length - 1];
174
+ const lastKey = last?.key;
175
+ if (!lastKey)
176
+ return text;
177
+ const indent = lineIndent(text, lastKey.start);
178
+ const [, end] = expandLine(text, lastKey.start, (last?.value ?? lastKey).end);
179
+ const lead = end > 0 && text.charCodeAt(end - 1) !== 10 ? '\n' : '';
180
+ return splice(text, end, end, `${lead}${indent}${pair}\n`);
181
+ }
182
+ case 'insertItem': {
183
+ const seq = yamlNodeAt(root, op.path);
184
+ if (!seq || !isSeq(seq))
185
+ return text;
186
+ const value = yamlInsertValue(op.value);
187
+ if (text.charCodeAt(seq.start) === 91 /* [ */) {
188
+ const items = seq.items.map((item) => text.slice(item.start, item.end));
189
+ items.splice(clampIndex(op.index, items.length), 0, value);
190
+ return splice(text, seq.start + 1, seq.end - 1, items.join(', '));
191
+ }
192
+ // Block sequence: we need an existing `- item` line to mirror its indentation and style.
193
+ if (seq.items.length === 0)
194
+ return text;
195
+ const blocks = seq.items.map((item) => expandLine(text, item.start, item.end));
196
+ const regionStart = blocks[0][0];
197
+ const regionEnd = blocks[blocks.length - 1][1];
198
+ const lines = seq.items.map((_, index) => text.slice(...blocks[index]));
199
+ lines.splice(clampIndex(op.index, lines.length), 0, `${lineIndent(text, regionStart)}- ${value}\n`);
200
+ return splice(text, regionStart, regionEnd, lines.join(''));
201
+ }
202
+ }
203
+ };
204
+ /** Clamps an optional insertion index into `[0, length]`, defaulting to an append. */
205
+ const clampIndex = (index, length) => Math.max(0, Math.min(index ?? length, length));
206
+ /**
207
+ * Rewrites a YAML sequence to contain exactly `newItems` (a subset and/or
208
+ * reordering of the original nodes), preserving each kept item's own text. Flow
209
+ * sequences (`[a, b]`) are rebuilt inside their brackets; block sequences (one
210
+ * `- item` per line) are rebuilt from their whole-line spans.
211
+ */
212
+ const rewriteYamlSeq = (text, seq, newItems) => {
213
+ const isFlow = text.charCodeAt(seq.start) === 91; /* [ */
214
+ if (isFlow) {
215
+ const inner = newItems.map((item) => text.slice(item.start, item.end)).join(', ');
216
+ return splice(text, seq.start + 1, seq.end - 1, inner);
217
+ }
218
+ if (seq.items.length === 0)
219
+ return text;
220
+ const blocks = seq.items.map((item) => expandLine(text, item.start, item.end));
221
+ const regionStart = blocks[0][0];
222
+ const regionEnd = blocks[blocks.length - 1][1];
223
+ const blockText = new Map(seq.items.map((item, index) => [item, text.slice(...blocks[index])]));
224
+ const rebuilt = newItems.map((item) => blockText.get(item) ?? '').join('');
225
+ return splice(text, regionStart, regionEnd, rebuilt);
226
+ };
227
+ // --- JSON ------------------------------------------------------------------
228
+ const JSON_FORMAT = { insertSpaces: true, tabSize: 2, eol: '\n' };
229
+ /**
230
+ * jsonc-parser keys path segments by JS type — strings index objects, numbers
231
+ * index arrays — but a finding's path carries numeric-like object keys (e.g. a
232
+ * `"200"` response) as plain numbers. Walk the tree and coerce each segment to
233
+ * the type its parent expects, so both keys and indices resolve (and `modify`
234
+ * doesn't mistake a `"200"` key for an array index and throw).
235
+ */
236
+ const normalizeJsonPath = (root, path) => {
237
+ const result = [];
238
+ let node = root;
239
+ for (const segment of path) {
240
+ const normalized = node?.type === 'array' ? Number(segment) : String(segment);
241
+ result.push(normalized);
242
+ node = node ? findNodeAtLocation(node, [normalized]) : undefined;
243
+ }
244
+ return result;
245
+ };
246
+ const applyJsonOp = (text, op) => {
247
+ const root = parseTree(text);
248
+ if (!root)
249
+ return text;
250
+ const path = normalizeJsonPath(root, op.path);
251
+ switch (op.op) {
252
+ case 'setValue':
253
+ return applyEdits(text, modify(text, path, op.value, { formattingOptions: JSON_FORMAT }));
254
+ case 'removeProperty':
255
+ return applyEdits(text, modify(text, path, undefined, { formattingOptions: JSON_FORMAT }));
256
+ case 'renameProperty': {
257
+ const valueNode = findNodeAtLocation(root, path);
258
+ const keyNode = valueNode?.parent?.children?.[0];
259
+ if (!keyNode)
260
+ return text;
261
+ return splice(text, keyNode.offset, keyNode.offset + keyNode.length, JSON.stringify(op.newKey));
262
+ }
263
+ case 'removeItems': {
264
+ // jsonc-parser's per-index array removal is unreliable, so rewrite the
265
+ // whole array with the kept elements instead.
266
+ const node = findNodeAtLocation(root, path);
267
+ if (!node)
268
+ return text;
269
+ const removed = new Set(op.indices);
270
+ const kept = getNodeValue(node).filter((_, index) => !removed.has(index));
271
+ return applyEdits(text, modify(text, path, kept, { formattingOptions: JSON_FORMAT }));
272
+ }
273
+ case 'reorderArray': {
274
+ const node = findNodeAtLocation(root, path);
275
+ if (!node)
276
+ return text;
277
+ const array = getNodeValue(node);
278
+ const reordered = op.order.map((index) => array[index]);
279
+ return applyEdits(text, modify(text, path, reordered, { formattingOptions: JSON_FORMAT }));
280
+ }
281
+ case 'insertProperty': {
282
+ const node = findNodeAtLocation(root, path);
283
+ if (!node || node.type !== 'object')
284
+ return text;
285
+ // Additive only: writing to an existing key would replace its value, so bail.
286
+ if (node.children?.some((property) => property.children?.[0]?.value === op.key))
287
+ return text;
288
+ return applyEdits(text, modify(text, [...path, op.key], op.value, { formattingOptions: JSON_FORMAT }));
289
+ }
290
+ case 'insertItem': {
291
+ const node = findNodeAtLocation(root, path);
292
+ if (!node || node.type !== 'array')
293
+ return text;
294
+ const index = clampIndex(op.index, node.children?.length ?? 0);
295
+ return applyEdits(text, modify(text, [...path, index], op.value, { formattingOptions: JSON_FORMAT, isArrayInsertion: true }));
296
+ }
297
+ }
298
+ };
299
+ /**
300
+ * Applies `ops` to `source`, reporting both the edited text and, per op, whether
301
+ * it actually changed anything. Each op is lowered to a minimal text edit and
302
+ * applied sequentially (re-parsing between ops so offsets stay valid), preserving
303
+ * the formatting of everything it does not touch. An op whose target path no
304
+ * longer resolves is a no-op rather than an error — callers use `changed` to tell
305
+ * a real fix from one that quietly dropped out (e.g. its node was already gone).
306
+ */
307
+ export const applyEditOpsWithChanges = (source, format, ops) => {
308
+ let text = source;
309
+ const changed = [];
310
+ for (const op of ops) {
311
+ const before = text;
312
+ // An op that can't be lowered (an unresolved path, a key the underlying
313
+ // editor rejects) must be a no-op, never a thrown error that aborts the
314
+ // whole batch — one bad edit should not drop every other fix.
315
+ try {
316
+ text = format === 'json' ? applyJsonOp(text, op) : applyYamlOp(text, op);
317
+ }
318
+ catch {
319
+ text = before;
320
+ }
321
+ changed.push(text !== before);
322
+ }
323
+ return { output: text, changed };
324
+ };
325
+ /** Applies `ops` to `source` and returns just the edited text. See {@link applyEditOpsWithChanges}. */
326
+ export const applyEditOps = (source, format, ops) => applyEditOpsWithChanges(source, format, ops).output;
@@ -0,0 +1,18 @@
1
+ export { type ApplyEditOpsResult, applyEditOps, applyEditOpsWithChanges, type EditOp } from './edit-model.js';
2
+ export { parseJson } from './json.js';
3
+ export { createLineMap, type LineMap } from './lines.js';
4
+ export * from './types.js';
5
+ export { parseYaml } from './yaml.js';
6
+ import type { IParseResult, IParserOptions } from './types.js';
7
+ /** Which concrete parser a document is routed to. */
8
+ export type ParserFormat = 'yaml' | 'json';
9
+ /** Guesses the format from the first non-whitespace character (`{`/`[` ⇒ JSON, else YAML). */
10
+ export declare const detectFormat: (source: string) => ParserFormat;
11
+ /**
12
+ * Parses a document with source maps. YAML is a JSON superset, so the YAML
13
+ * parser is the default and handles both; the JSON parser is used when a strict
14
+ * JSON document is detected or requested (it reports JSON-specific errors).
15
+ */
16
+ export declare const parseWithPointers: <T = unknown>(source: string, options?: IParserOptions & {
17
+ format?: ParserFormat;
18
+ }) => IParseResult<T>;
@@ -0,0 +1,21 @@
1
+ export { applyEditOps, applyEditOpsWithChanges } from './edit-model.js';
2
+ export { parseJson } from './json.js';
3
+ export { createLineMap } from './lines.js';
4
+ export * from './types.js';
5
+ export { parseYaml } from './yaml.js';
6
+ import { parseJson } from './json.js';
7
+ import { parseYaml } from './yaml.js';
8
+ /** Guesses the format from the first non-whitespace character (`{`/`[` ⇒ JSON, else YAML). */
9
+ export const detectFormat = (source) => {
10
+ const trimmed = source.trimStart();
11
+ return trimmed.startsWith('{') || trimmed.startsWith('[') ? 'json' : 'yaml';
12
+ };
13
+ /**
14
+ * Parses a document with source maps. YAML is a JSON superset, so the YAML
15
+ * parser is the default and handles both; the JSON parser is used when a strict
16
+ * JSON document is detected or requested (it reports JSON-specific errors).
17
+ */
18
+ export const parseWithPointers = (source, options = {}) => {
19
+ const format = options.format ?? detectFormat(source);
20
+ return format === 'json' ? parseJson(source) : parseYaml(source, options);
21
+ };
@@ -0,0 +1,3 @@
1
+ import { type IParseResult } from './types.js';
2
+ /** Parses strict JSON into data plus a source map, reporting JSON-specific syntax errors. */
3
+ export declare const parseJson: <T = unknown>(source: string) => IParseResult<T>;
@@ -0,0 +1,38 @@
1
+ import { findNodeAtLocation, getNodeValue, parseTree, printParseErrorCode } from 'jsonc-parser';
2
+ import { createLineMap } from './lines.js';
3
+ import { DiagnosticSeverity } from './types.js';
4
+ /** Parses strict JSON into data plus a source map, reporting JSON-specific syntax errors. */
5
+ export const parseJson = (source) => {
6
+ const lineMap = createLineMap(source);
7
+ const errors = [];
8
+ const root = parseTree(source, errors, { allowTrailingComma: false, disallowComments: true });
9
+ const data = (root ? getNodeValue(root) : undefined);
10
+ const diagnostics = errors.map((err) => ({
11
+ message: printParseErrorCode(err.error),
12
+ severity: DiagnosticSeverity.Error,
13
+ range: {
14
+ start: lineMap.positionAt(err.offset),
15
+ end: lineMap.positionAt(err.offset + err.length),
16
+ },
17
+ }));
18
+ const getLocationForJsonPath = (path, closest = false) => {
19
+ if (!root)
20
+ return undefined;
21
+ const p = path.slice();
22
+ while (true) {
23
+ const node = findNodeAtLocation(root, p);
24
+ if (node) {
25
+ return {
26
+ range: {
27
+ start: lineMap.positionAt(node.offset),
28
+ end: lineMap.positionAt(node.offset + node.length),
29
+ },
30
+ };
31
+ }
32
+ if (!closest || p.length === 0)
33
+ return undefined;
34
+ p.pop();
35
+ }
36
+ };
37
+ return { data, diagnostics, getLocationForJsonPath };
38
+ };
@@ -0,0 +1,13 @@
1
+ import type { IPosition } from './types.js';
2
+ /**
3
+ * Resolves byte/char offsets to `{ line, character }` positions. Lines and
4
+ * characters are zero-based, matching LSP / Linter conventions.
5
+ */
6
+ export type LineMap = {
7
+ positionAt(offset: number): IPosition;
8
+ };
9
+ /**
10
+ * Builds a {@link LineMap} for `source`. The line-start offsets are precomputed
11
+ * once so each `positionAt` lookup is a binary search rather than a re-scan.
12
+ */
13
+ export declare const createLineMap: (source: string) => LineMap;