@sentriflow/core 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.
Files changed (71) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +86 -0
  3. package/package.json +60 -0
  4. package/src/constants.ts +77 -0
  5. package/src/engine/RuleExecutor.ts +256 -0
  6. package/src/engine/Runner.ts +312 -0
  7. package/src/engine/SandboxedExecutor.ts +208 -0
  8. package/src/errors.ts +88 -0
  9. package/src/helpers/arista/helpers.ts +1220 -0
  10. package/src/helpers/arista/index.ts +12 -0
  11. package/src/helpers/aruba/helpers.ts +637 -0
  12. package/src/helpers/aruba/index.ts +13 -0
  13. package/src/helpers/cisco/helpers.ts +534 -0
  14. package/src/helpers/cisco/index.ts +11 -0
  15. package/src/helpers/common/helpers.ts +265 -0
  16. package/src/helpers/common/index.ts +5 -0
  17. package/src/helpers/common/validation.ts +280 -0
  18. package/src/helpers/cumulus/helpers.ts +676 -0
  19. package/src/helpers/cumulus/index.ts +12 -0
  20. package/src/helpers/extreme/helpers.ts +422 -0
  21. package/src/helpers/extreme/index.ts +12 -0
  22. package/src/helpers/fortinet/helpers.ts +892 -0
  23. package/src/helpers/fortinet/index.ts +12 -0
  24. package/src/helpers/huawei/helpers.ts +790 -0
  25. package/src/helpers/huawei/index.ts +11 -0
  26. package/src/helpers/index.ts +53 -0
  27. package/src/helpers/juniper/helpers.ts +756 -0
  28. package/src/helpers/juniper/index.ts +12 -0
  29. package/src/helpers/mikrotik/helpers.ts +722 -0
  30. package/src/helpers/mikrotik/index.ts +12 -0
  31. package/src/helpers/nokia/helpers.ts +856 -0
  32. package/src/helpers/nokia/index.ts +11 -0
  33. package/src/helpers/paloalto/helpers.ts +939 -0
  34. package/src/helpers/paloalto/index.ts +12 -0
  35. package/src/helpers/vyos/helpers.ts +429 -0
  36. package/src/helpers/vyos/index.ts +12 -0
  37. package/src/index.ts +30 -0
  38. package/src/json-rules/ExpressionEvaluator.ts +292 -0
  39. package/src/json-rules/HelperRegistry.ts +177 -0
  40. package/src/json-rules/JsonRuleCompiler.ts +339 -0
  41. package/src/json-rules/JsonRuleValidator.ts +371 -0
  42. package/src/json-rules/index.ts +97 -0
  43. package/src/json-rules/schema.json +350 -0
  44. package/src/json-rules/types.ts +303 -0
  45. package/src/pack-loader/PackLoader.ts +332 -0
  46. package/src/pack-loader/index.ts +17 -0
  47. package/src/pack-loader/types.ts +135 -0
  48. package/src/parser/IncrementalParser.ts +527 -0
  49. package/src/parser/Sanitizer.ts +104 -0
  50. package/src/parser/SchemaAwareParser.ts +504 -0
  51. package/src/parser/VendorSchema.ts +72 -0
  52. package/src/parser/vendors/arista-eos.ts +206 -0
  53. package/src/parser/vendors/aruba-aoscx.ts +123 -0
  54. package/src/parser/vendors/aruba-aosswitch.ts +113 -0
  55. package/src/parser/vendors/aruba-wlc.ts +173 -0
  56. package/src/parser/vendors/cisco-ios.ts +110 -0
  57. package/src/parser/vendors/cisco-nxos.ts +107 -0
  58. package/src/parser/vendors/cumulus-linux.ts +161 -0
  59. package/src/parser/vendors/extreme-exos.ts +154 -0
  60. package/src/parser/vendors/extreme-voss.ts +167 -0
  61. package/src/parser/vendors/fortinet-fortigate.ts +217 -0
  62. package/src/parser/vendors/huawei-vrp.ts +192 -0
  63. package/src/parser/vendors/index.ts +1521 -0
  64. package/src/parser/vendors/juniper-junos.ts +230 -0
  65. package/src/parser/vendors/mikrotik-routeros.ts +274 -0
  66. package/src/parser/vendors/nokia-sros.ts +251 -0
  67. package/src/parser/vendors/paloalto-panos.ts +264 -0
  68. package/src/parser/vendors/vyos-vyos.ts +454 -0
  69. package/src/types/ConfigNode.ts +72 -0
  70. package/src/types/DeclarativeRule.ts +158 -0
  71. package/src/types/IRule.ts +270 -0
@@ -0,0 +1,135 @@
1
+ // packages/core/src/pack-loader/types.ts
2
+
3
+ /**
4
+ * SEC-012: Types for the encrypted rule pack system (Consumer API).
5
+ *
6
+ * This module provides types for LOADING encrypted rule packs.
7
+ * For BUILDING packs, use the separate @sentriflow/pack-builder package.
8
+ */
9
+
10
+ import type { IRule, RulePackMetadata } from '../types/IRule';
11
+
12
+ /**
13
+ * Error codes for pack loading failures.
14
+ */
15
+ export const PackLoadErrors = {
16
+ INVALID_FORMAT: 'PACK_INVALID_FORMAT',
17
+ DECRYPTION_FAILED: 'PACK_DECRYPTION_FAILED',
18
+ VALIDATION_FAILED: 'PACK_VALIDATION_FAILED',
19
+ EXPIRED: 'PACK_EXPIRED',
20
+ MACHINE_MISMATCH: 'PACK_MACHINE_MISMATCH',
21
+ ACTIVATION_LIMIT: 'PACK_ACTIVATION_LIMIT',
22
+ EXECUTION_ERROR: 'PACK_EXECUTION_ERROR',
23
+ } as const;
24
+
25
+ export type PackLoadErrorCode = keyof typeof PackLoadErrors;
26
+
27
+ /**
28
+ * Error thrown when loading an encrypted pack fails.
29
+ */
30
+ export class PackLoadError extends Error {
31
+ constructor(
32
+ public readonly code: PackLoadErrorCode,
33
+ message: string,
34
+ public readonly details?: Record<string, unknown>
35
+ ) {
36
+ super(message);
37
+ this.name = 'PackLoadError';
38
+ if (Error.captureStackTrace) {
39
+ Error.captureStackTrace(this, this.constructor);
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Options for loading an encrypted pack.
46
+ */
47
+ export interface PackLoadOptions {
48
+ /** The license key for decryption */
49
+ licenseKey: string;
50
+ /** Optional machine ID for node-locked licenses */
51
+ machineId?: string;
52
+ /** Optional callback to get current activation count */
53
+ getActivationCount?: (packName: string) => number;
54
+ /** Execution timeout in milliseconds (default: 5000) */
55
+ timeout?: number;
56
+ }
57
+
58
+ /**
59
+ * License information embedded in the pack.
60
+ * Displayed to users to show their subscription details.
61
+ */
62
+ export interface LicenseInfo {
63
+ /** Customer name */
64
+ customerName: string;
65
+ /** Customer email */
66
+ customerEmail: string;
67
+ /** Company name (if provided) */
68
+ company?: string;
69
+ /** Contract/subscription ID (if provided) */
70
+ contractId?: string;
71
+ /** ISO date string when the license was activated */
72
+ activatedAt: string;
73
+ /** ISO date string when the license expires */
74
+ expiresAt: string;
75
+ }
76
+
77
+ /**
78
+ * Result of successfully loading an encrypted pack.
79
+ */
80
+ export interface LoadedPack {
81
+ /** Pack metadata */
82
+ metadata: RulePackMetadata;
83
+ /** Validated and ready-to-use rules */
84
+ rules: IRule[];
85
+ /** ISO date string of when the pack expires */
86
+ validUntil: string;
87
+ /** License information for display purposes */
88
+ licenseInfo?: LicenseInfo;
89
+ }
90
+
91
+ /**
92
+ * Encrypted rule pack file format (.grpx).
93
+ *
94
+ * Binary structure:
95
+ * - magic: 4 bytes ("GRPX")
96
+ * - version: 1 byte (format version)
97
+ * - algorithm: 1 byte (1=AES-256-GCM)
98
+ * - kdf: 1 byte (1=PBKDF2, 2=Argon2id)
99
+ * - reserved: 5 bytes
100
+ * - iv: 12 bytes
101
+ * - tag: 16 bytes (GCM auth tag)
102
+ * - salt: 32 bytes (KDF salt)
103
+ * - payloadLength: 4 bytes (uint32 BE)
104
+ * - encryptedPayload: variable length
105
+ *
106
+ * Total header: 76 bytes + payload
107
+ */
108
+ export interface EncryptedPackHeader {
109
+ magic: 'GRPX';
110
+ version: number;
111
+ algorithm: number;
112
+ kdf: number;
113
+ iv: Buffer;
114
+ tag: Buffer;
115
+ salt: Buffer;
116
+ payloadLength: number;
117
+ }
118
+
119
+ /**
120
+ * Constants for the encrypted pack format.
121
+ * Shared between builder (@sentriflow/pack-builder) and loader (@sentriflow/core).
122
+ */
123
+ export const GRPX_CONSTANTS = {
124
+ MAGIC: 'GRPX',
125
+ HEADER_SIZE: 76,
126
+ CURRENT_VERSION: 1,
127
+ ALG_AES_256_GCM: 1,
128
+ KDF_PBKDF2: 1,
129
+ KDF_ARGON2ID: 2,
130
+ PBKDF2_ITERATIONS: 100000,
131
+ KEY_LENGTH: 32,
132
+ IV_LENGTH: 12,
133
+ TAG_LENGTH: 16,
134
+ SALT_LENGTH: 32,
135
+ } as const;
@@ -0,0 +1,527 @@
1
+ // packages/core/src/parser/IncrementalParser.ts
2
+
3
+ import type { ConfigNode } from '../types/ConfigNode';
4
+ import { SchemaAwareParser } from './SchemaAwareParser';
5
+ import type { ParserOptions } from './SchemaAwareParser';
6
+ import type { VendorSchema } from './VendorSchema';
7
+ import { defaultVendor, detectVendor } from './vendors';
8
+ import { INCREMENTAL_PARSE_THRESHOLD } from '../constants';
9
+
10
+ /**
11
+ * Options for the IncrementalParser.
12
+ */
13
+ export interface IncrementalParserOptions {
14
+ /**
15
+ * Vendor schema to use for parsing.
16
+ * - If not specified, defaults to auto-detection.
17
+ * - Use 'auto' to auto-detect vendor from config content.
18
+ * - Use a specific VendorSchema for explicit vendor selection.
19
+ */
20
+ vendor?: VendorSchema | 'auto';
21
+ }
22
+
23
+ /**
24
+ * Cached document state for incremental parsing.
25
+ */
26
+ interface DocumentCache {
27
+ /** The parsed AST */
28
+ ast: ConfigNode[];
29
+ /** Hash of each line for change detection */
30
+ lineHashes: string[];
31
+ /** Document version (increments on each edit) */
32
+ version: number;
33
+ /** Original line count */
34
+ lineCount: number;
35
+ /** Vendor used for parsing this document */
36
+ vendor: VendorSchema;
37
+ }
38
+
39
+ /**
40
+ * Represents a range of changed lines.
41
+ */
42
+ interface ChangeRange {
43
+ startLine: number;
44
+ endLine: number;
45
+ }
46
+
47
+ /**
48
+ * Statistics about the last parse operation.
49
+ */
50
+ export interface ParseStats {
51
+ /** Whether a full parse was performed */
52
+ fullParse: boolean;
53
+ /** Number of changed line ranges detected */
54
+ changedRanges: number;
55
+ /** Number of sections re-parsed (if incremental) */
56
+ sectionsReparsed: number;
57
+ /** Parse time in milliseconds */
58
+ parseTimeMs: number;
59
+ /** Reason for full parse (if applicable) */
60
+ fullParseReason?: string;
61
+ /** Vendor used for parsing */
62
+ vendorId?: string;
63
+ }
64
+
65
+ /**
66
+ * Incremental parser that caches ASTs and only re-parses changed sections.
67
+ * Supports multiple vendors through the VendorSchema system.
68
+ *
69
+ * Performance characteristics:
70
+ * - Single line edit: Only affected section re-parsed (~5-20x faster)
71
+ * - Multi-line paste: Affected sections re-parsed (~2-10x faster)
72
+ * - Large changes (>30%): Falls back to full re-parse
73
+ *
74
+ * Vendor support:
75
+ * - Auto-detection: Analyzes config content to determine vendor
76
+ * - Explicit vendor: Pass VendorSchema in options
77
+ * - Cached vendor: Re-uses detected vendor for subsequent parses
78
+ *
79
+ * Usage:
80
+ * ```typescript
81
+ * const parser = new IncrementalParser();
82
+ *
83
+ * // First parse - auto-detects vendor, full parse, cached
84
+ * const ast1 = parser.parse('doc1', content1, 1);
85
+ *
86
+ * // Edit - incremental parse if possible, same vendor
87
+ * const ast2 = parser.parse('doc1', content2, 2);
88
+ *
89
+ * // Explicit vendor
90
+ * const parser2 = new IncrementalParser({ vendor: JuniperJunOSSchema });
91
+ * const ast3 = parser2.parse('junos-config', junosContent, 1);
92
+ *
93
+ * // Clear cache when document closes
94
+ * parser.invalidate('doc1');
95
+ * ```
96
+ */
97
+ export class IncrementalParser {
98
+ private cache = new Map<string, DocumentCache>();
99
+ private lastStats: ParseStats | null = null;
100
+ private readonly defaultVendorOption: VendorSchema | 'auto';
101
+
102
+ constructor(options?: IncrementalParserOptions) {
103
+ this.defaultVendorOption = options?.vendor ?? 'auto';
104
+ }
105
+
106
+ /**
107
+ * Parse document content, using cached AST when possible.
108
+ *
109
+ * @param uri Unique identifier for the document (e.g., file URI)
110
+ * @param content The document content to parse
111
+ * @param version Document version number (should increment on each edit)
112
+ * @param vendor Optional vendor override for this specific parse
113
+ * @returns Parsed AST
114
+ */
115
+ public parse(
116
+ uri: string,
117
+ content: string,
118
+ version: number,
119
+ vendor?: VendorSchema | 'auto'
120
+ ): ConfigNode[] {
121
+ const startTime = performance.now();
122
+ const cached = this.cache.get(uri);
123
+ const lines = content.split('\n');
124
+ const lineHashes = lines.map((line) => this.hashLine(line));
125
+
126
+ // Determine vendor to use
127
+ const vendorOption = vendor ?? this.defaultVendorOption;
128
+ const resolvedVendor = this.resolveVendor(vendorOption, content, cached);
129
+
130
+ // Full parse if no cache
131
+ if (!cached) {
132
+ const ast = this.fullParse(content, resolvedVendor);
133
+ this.cache.set(uri, { ast, lineHashes, version, lineCount: lines.length, vendor: resolvedVendor });
134
+ this.lastStats = {
135
+ fullParse: true,
136
+ changedRanges: 0,
137
+ sectionsReparsed: 0,
138
+ parseTimeMs: performance.now() - startTime,
139
+ fullParseReason: 'no_cache',
140
+ vendorId: resolvedVendor.id,
141
+ };
142
+ return ast;
143
+ }
144
+
145
+ // Full parse if vendor changed
146
+ if (cached.vendor.id !== resolvedVendor.id) {
147
+ const ast = this.fullParse(content, resolvedVendor);
148
+ this.cache.set(uri, { ast, lineHashes, version, lineCount: lines.length, vendor: resolvedVendor });
149
+ this.lastStats = {
150
+ fullParse: true,
151
+ changedRanges: 0,
152
+ sectionsReparsed: 0,
153
+ parseTimeMs: performance.now() - startTime,
154
+ fullParseReason: 'vendor_changed',
155
+ vendorId: resolvedVendor.id,
156
+ };
157
+ return ast;
158
+ }
159
+
160
+ // Full parse if version hasn't increased (stale request)
161
+ if (version <= cached.version) {
162
+ this.lastStats = {
163
+ fullParse: false,
164
+ changedRanges: 0,
165
+ sectionsReparsed: 0,
166
+ parseTimeMs: performance.now() - startTime,
167
+ vendorId: resolvedVendor.id,
168
+ };
169
+ return cached.ast;
170
+ }
171
+
172
+ // Find changed line ranges
173
+ const changedRanges = this.findChangedRanges(cached.lineHashes, lineHashes);
174
+
175
+ // Calculate percentage of lines changed
176
+ const totalChangedLines = changedRanges.reduce(
177
+ (sum, range) => sum + (range.endLine - range.startLine + 1),
178
+ 0
179
+ );
180
+ const changeRatio = totalChangedLines / Math.max(lines.length, cached.lineCount);
181
+
182
+ // Full parse if too many changes or structural changes detected
183
+ if (changeRatio > INCREMENTAL_PARSE_THRESHOLD || this.hasStructuralChanges(changedRanges, cached, lines)) {
184
+ const ast = this.fullParse(content, resolvedVendor);
185
+ this.cache.set(uri, { ast, lineHashes, version, lineCount: lines.length, vendor: resolvedVendor });
186
+ this.lastStats = {
187
+ fullParse: true,
188
+ changedRanges: changedRanges.length,
189
+ sectionsReparsed: 0,
190
+ parseTimeMs: performance.now() - startTime,
191
+ fullParseReason: changeRatio > INCREMENTAL_PARSE_THRESHOLD ? 'too_many_changes' : 'structural_changes',
192
+ vendorId: resolvedVendor.id,
193
+ };
194
+ return ast;
195
+ }
196
+
197
+ // No changes detected
198
+ if (changedRanges.length === 0) {
199
+ cached.version = version;
200
+ this.lastStats = {
201
+ fullParse: false,
202
+ changedRanges: 0,
203
+ sectionsReparsed: 0,
204
+ parseTimeMs: performance.now() - startTime,
205
+ vendorId: resolvedVendor.id,
206
+ };
207
+ return cached.ast;
208
+ }
209
+
210
+ // Incremental update
211
+ const { ast: updatedAst, sectionsReparsed } = this.incrementalUpdate(
212
+ cached.ast,
213
+ lines,
214
+ changedRanges,
215
+ resolvedVendor
216
+ );
217
+
218
+ this.cache.set(uri, { ast: updatedAst, lineHashes, version, lineCount: lines.length, vendor: resolvedVendor });
219
+ this.lastStats = {
220
+ fullParse: false,
221
+ changedRanges: changedRanges.length,
222
+ sectionsReparsed,
223
+ parseTimeMs: performance.now() - startTime,
224
+ vendorId: resolvedVendor.id,
225
+ };
226
+
227
+ return updatedAst;
228
+ }
229
+
230
+ /**
231
+ * Resolve the vendor to use for parsing.
232
+ * @param vendorOption The vendor option (VendorSchema or 'auto')
233
+ * @param content The config content for auto-detection
234
+ * @param cached Optional cached document for reusing vendor
235
+ */
236
+ private resolveVendor(
237
+ vendorOption: VendorSchema | 'auto',
238
+ content: string,
239
+ cached?: DocumentCache
240
+ ): VendorSchema {
241
+ if (vendorOption !== 'auto') {
242
+ return vendorOption;
243
+ }
244
+
245
+ // If cached and auto, reuse the detected vendor for consistency
246
+ if (cached) {
247
+ return cached.vendor;
248
+ }
249
+
250
+ // Auto-detect from content
251
+ return detectVendor(content);
252
+ }
253
+
254
+ /**
255
+ * Perform a full parse using SchemaAwareParser.
256
+ */
257
+ private fullParse(content: string, vendor: VendorSchema): ConfigNode[] {
258
+ const parser = new SchemaAwareParser({ vendor });
259
+ return parser.parse(content);
260
+ }
261
+
262
+ /**
263
+ * Simple string hash for fast line comparison.
264
+ * Uses djb2 algorithm for good distribution.
265
+ */
266
+ private hashLine(line: string): string {
267
+ let hash = 5381;
268
+ for (let i = 0; i < line.length; i++) {
269
+ hash = ((hash << 5) + hash) ^ line.charCodeAt(i);
270
+ }
271
+ return (hash >>> 0).toString(36);
272
+ }
273
+
274
+ /**
275
+ * Find ranges of consecutive changed lines.
276
+ */
277
+ private findChangedRanges(oldHashes: string[], newHashes: string[]): ChangeRange[] {
278
+ const ranges: ChangeRange[] = [];
279
+ let inChange = false;
280
+ let changeStart = 0;
281
+
282
+ const maxLen = Math.max(oldHashes.length, newHashes.length);
283
+
284
+ for (let i = 0; i < maxLen; i++) {
285
+ const changed = oldHashes[i] !== newHashes[i];
286
+
287
+ if (changed && !inChange) {
288
+ inChange = true;
289
+ changeStart = i;
290
+ } else if (!changed && inChange) {
291
+ inChange = false;
292
+ ranges.push({
293
+ startLine: changeStart,
294
+ endLine: i - 1,
295
+ });
296
+ }
297
+ }
298
+
299
+ // Handle change that extends to end of file
300
+ if (inChange) {
301
+ ranges.push({
302
+ startLine: changeStart,
303
+ endLine: maxLen - 1,
304
+ });
305
+ }
306
+
307
+ return ranges;
308
+ }
309
+
310
+ /**
311
+ * Detect structural changes that require full re-parse.
312
+ * Examples: Line count changed significantly, changes span multiple sections.
313
+ */
314
+ private hasStructuralChanges(
315
+ changedRanges: ChangeRange[],
316
+ cached: DocumentCache,
317
+ newLines: string[]
318
+ ): boolean {
319
+ // Significant line count change (insertions/deletions)
320
+ const lineDelta = Math.abs(newLines.length - cached.lineCount);
321
+ if (lineDelta > 10) {
322
+ return true;
323
+ }
324
+
325
+ // Changes affect multiple top-level sections
326
+ let affectedSections = 0;
327
+ for (const range of changedRanges) {
328
+ for (const node of cached.ast) {
329
+ if (this.rangeOverlapsNode(range, node)) {
330
+ affectedSections++;
331
+ }
332
+ }
333
+ }
334
+
335
+ // If changes affect more than half of top-level sections, do full parse
336
+ if (affectedSections > cached.ast.length / 2) {
337
+ return true;
338
+ }
339
+
340
+ return false;
341
+ }
342
+
343
+ /**
344
+ * Check if a change range overlaps with a node's location.
345
+ */
346
+ private rangeOverlapsNode(range: ChangeRange, node: ConfigNode): boolean {
347
+ return range.startLine <= node.loc.endLine && range.endLine >= node.loc.startLine;
348
+ }
349
+
350
+ /**
351
+ * Incrementally update AST by re-parsing only affected sections.
352
+ */
353
+ private incrementalUpdate(
354
+ oldAst: ConfigNode[],
355
+ lines: string[],
356
+ changedRanges: ChangeRange[],
357
+ vendor: VendorSchema
358
+ ): { ast: ConfigNode[]; sectionsReparsed: number } {
359
+ // Find which top-level sections are affected by changes
360
+ const affectedSectionIndices = new Set<number>();
361
+
362
+ for (const range of changedRanges) {
363
+ for (let i = 0; i < oldAst.length; i++) {
364
+ const node = oldAst[i];
365
+ if (node && this.rangeOverlapsNode(range, node)) {
366
+ affectedSectionIndices.add(i);
367
+ }
368
+ }
369
+ }
370
+
371
+ // If no sections affected but we have changes, changes are in gaps between sections
372
+ // or at the end of file - do full re-parse to be safe
373
+ if (affectedSectionIndices.size === 0 && changedRanges.length > 0) {
374
+ const parser = new SchemaAwareParser({ vendor });
375
+ return { ast: parser.parse(lines.join('\n')), sectionsReparsed: oldAst.length };
376
+ }
377
+
378
+ // Calculate line offset caused by insertions/deletions
379
+ // For simplicity, we'll re-parse affected sections with adjusted line numbers
380
+ const newAst: ConfigNode[] = [];
381
+ let lineOffset = 0;
382
+
383
+ for (let i = 0; i < oldAst.length; i++) {
384
+ const node = oldAst[i];
385
+ if (!node) continue;
386
+
387
+ if (affectedSectionIndices.has(i)) {
388
+ // Find the section boundaries in the new content
389
+ const sectionStart = node.loc.startLine + lineOffset;
390
+ const nextNode = i < oldAst.length - 1 ? oldAst[i + 1] : null;
391
+ const sectionEnd = this.findSectionEnd(lines, sectionStart, nextNode ?? null, lineOffset);
392
+
393
+ if (sectionStart < lines.length) {
394
+ // Extract and re-parse this section
395
+ const sectionLines = lines.slice(sectionStart, sectionEnd + 1);
396
+ const sectionContent = sectionLines.join('\n');
397
+
398
+ const sectionParser = new SchemaAwareParser({
399
+ startLine: sectionStart,
400
+ vendor,
401
+ });
402
+
403
+ const reparsedNodes = sectionParser.parse(sectionContent);
404
+
405
+ // Add re-parsed nodes
406
+ newAst.push(...reparsedNodes);
407
+
408
+ // Update line offset based on size change
409
+ const oldSectionSize = node.loc.endLine - node.loc.startLine + 1;
410
+ const newSectionSize = sectionEnd - sectionStart + 1;
411
+ lineOffset += newSectionSize - oldSectionSize;
412
+ }
413
+ } else {
414
+ // Keep existing node but adjust line numbers
415
+ const adjustedNode = this.adjustNodeLineNumbers(node, lineOffset);
416
+ newAst.push(adjustedNode);
417
+ }
418
+ }
419
+
420
+ return { ast: newAst, sectionsReparsed: affectedSectionIndices.size };
421
+ }
422
+
423
+ /**
424
+ * Find the end line of a section in the new content.
425
+ */
426
+ private findSectionEnd(
427
+ lines: string[],
428
+ sectionStart: number,
429
+ nextSection: ConfigNode | null,
430
+ lineOffset: number
431
+ ): number {
432
+ // If there's a next section, the current section ends just before it
433
+ if (nextSection) {
434
+ const nextStart = nextSection.loc.startLine + lineOffset;
435
+ // Find the last non-empty line before the next section
436
+ let end = nextStart - 1;
437
+ while (end > sectionStart && lines[end]?.trim() === '') {
438
+ end--;
439
+ }
440
+ return Math.max(sectionStart, end);
441
+ }
442
+
443
+ // Last section - extends to end of file
444
+ let end = lines.length - 1;
445
+ while (end > sectionStart && lines[end]?.trim() === '') {
446
+ end--;
447
+ }
448
+ return Math.max(sectionStart, end);
449
+ }
450
+
451
+ /**
452
+ * Recursively adjust line numbers in a node and its children.
453
+ */
454
+ private adjustNodeLineNumbers(node: ConfigNode, offset: number): ConfigNode {
455
+ if (offset === 0) {
456
+ return node;
457
+ }
458
+
459
+ return {
460
+ ...node,
461
+ loc: {
462
+ startLine: node.loc.startLine + offset,
463
+ endLine: node.loc.endLine + offset,
464
+ },
465
+ children: node.children.map((child) => this.adjustNodeLineNumbers(child, offset)),
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Get statistics about the last parse operation.
471
+ */
472
+ public getLastStats(): ParseStats | null {
473
+ return this.lastStats;
474
+ }
475
+
476
+ /**
477
+ * Get the vendor used for a cached document.
478
+ *
479
+ * @param uri The document URI
480
+ * @returns The VendorSchema or undefined if not cached
481
+ */
482
+ public getCachedVendor(uri: string): VendorSchema | undefined {
483
+ return this.cache.get(uri)?.vendor;
484
+ }
485
+
486
+ /**
487
+ * Clear cache for a specific document.
488
+ *
489
+ * @param uri The document URI to invalidate
490
+ */
491
+ public invalidate(uri: string): void {
492
+ this.cache.delete(uri);
493
+ }
494
+
495
+ /**
496
+ * Clear all cached documents.
497
+ */
498
+ public clearAll(): void {
499
+ this.cache.clear();
500
+ }
501
+
502
+ /**
503
+ * Get the number of cached documents.
504
+ */
505
+ public getCacheSize(): number {
506
+ return this.cache.size;
507
+ }
508
+
509
+ /**
510
+ * Check if a document is cached.
511
+ *
512
+ * @param uri The document URI to check
513
+ */
514
+ public isCached(uri: string): boolean {
515
+ return this.cache.has(uri);
516
+ }
517
+
518
+ /**
519
+ * Get cached document version.
520
+ *
521
+ * @param uri The document URI
522
+ * @returns The cached version or -1 if not cached
523
+ */
524
+ public getCachedVersion(uri: string): number {
525
+ return this.cache.get(uri)?.version ?? -1;
526
+ }
527
+ }