amxx-builder 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +111 -0
  2. package/README.md +485 -0
  3. package/action-entry.js +42 -0
  4. package/action.yml +55 -0
  5. package/defaults/amxbuild.defaults.yml +40 -0
  6. package/index.js +4 -0
  7. package/mcp/dep-resolver.js +75 -0
  8. package/mcp/handlers.js +862 -0
  9. package/mcp/mcp-server.js +112 -0
  10. package/mcp/registry.js +863 -0
  11. package/mcp/symbol-index.js +171 -0
  12. package/package.json +68 -0
  13. package/src/archiver.js +140 -0
  14. package/src/asset-fetcher.js +277 -0
  15. package/src/build-plan.js +101 -0
  16. package/src/build-service.js +188 -0
  17. package/src/cache-dir.js +21 -0
  18. package/src/cache-info.js +104 -0
  19. package/src/cli.js +302 -0
  20. package/src/collector.js +89 -0
  21. package/src/commands/build.js +49 -0
  22. package/src/commands/cache.js +77 -0
  23. package/src/commands/clean.js +33 -0
  24. package/src/commands/compile-renderer.js +38 -0
  25. package/src/commands/deploy.js +40 -0
  26. package/src/commands/deps-tree.js +92 -0
  27. package/src/commands/doctor.js +77 -0
  28. package/src/commands/dry-run.js +64 -0
  29. package/src/commands/init.js +228 -0
  30. package/src/commands/mcp.js +13 -0
  31. package/src/commands/releases.js +45 -0
  32. package/src/commands/resolve-manifest.js +27 -0
  33. package/src/commands/serve.js +489 -0
  34. package/src/commands/shared.js +24 -0
  35. package/src/commands/validate.js +34 -0
  36. package/src/commands/watch.js +209 -0
  37. package/src/compile-utils.js +65 -0
  38. package/src/compiler-fetcher.js +327 -0
  39. package/src/compiler.js +228 -0
  40. package/src/dep-graph.js +92 -0
  41. package/src/deployer.js +197 -0
  42. package/src/deps-resolver.js +127 -0
  43. package/src/deps-tree.js +202 -0
  44. package/src/env.js +18 -0
  45. package/src/events.js +29 -0
  46. package/src/format.js +23 -0
  47. package/src/fs-utils.js +87 -0
  48. package/src/include-tree.js +845 -0
  49. package/src/ini-builder.js +44 -0
  50. package/src/jsonrpc-transport.js +195 -0
  51. package/src/logger.js +50 -0
  52. package/src/manifest-path.js +34 -0
  53. package/src/manifest.js +373 -0
  54. package/src/progress.js +66 -0
  55. package/src/rcon.js +103 -0
  56. package/src/release-fetcher.js +206 -0
  57. package/src/release-lister.js +79 -0
  58. package/src/repo-fetcher.js +273 -0
  59. package/src/retry.js +50 -0
  60. package/src/schema.js +54 -0
  61. package/src/update-check.js +114 -0
  62. package/src/validate.js +69 -0
  63. package/src/watcher.js +135 -0
  64. package/templates/init-build.bat +11 -0
  65. package/templates/init-build.sh +7 -0
  66. package/templates/init-deploy.env +14 -0
  67. package/templates/init-manifest.yml +6 -0
  68. package/templates/init-workflow.yml +59 -0
@@ -0,0 +1,845 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AMXX Include Tree — bidirectional #include dependency graph builder.
5
+ *
6
+ * Core engine that:
7
+ * - Scans all .sma files (local + repos) as roots
8
+ * - Parses #include / #tryinclude directives in .sma and .inc files
9
+ * - Resolves includes using the same logic as amxxpc (quoted → local first,
10
+ * angle → include dirs)
11
+ * - Detects include guards (#if defined … #endinput … #endif … #define)
12
+ * to avoid re-expanding already-guarded files within a root's tree
13
+ * - Builds a bidirectional graph so you can traverse both DOWN
14
+ * (root → transitive includes) and UP (leaf → roots that reach it)
15
+ * - Formats the result as a human-readable tree or raw JSON
16
+ *
17
+ * Public API:
18
+ * const { buildIncludeTree } = require('./include-tree');
19
+ * const result = await buildIncludeTree(manifestPath, targetPath, options);
20
+ * // result.text — formatted tree string
21
+ * // result.tree — structured tree data (JSON-safe)
22
+ * // result.graph — full graph object (for programmatic use)
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const glob = require('fast-glob');
28
+
29
+ const { parseManifest, parseDepsLines, resolveGithubToken } = require('./manifest');
30
+ const { fetchCompiler, fetchLatestVersion } = require('./compiler-fetcher');
31
+ const { fetchRepo, resolveRefIfLatest, resolveRepoRefs } = require('./repo-fetcher');
32
+ const { fetchReleaseDep } = require('./release-fetcher');
33
+ const { normalize } = require('./deps-resolver');
34
+ const { loadEnv } = require('./env');
35
+ const { resolveManifestPath } = require('./manifest-path');
36
+
37
+ // ─── Regex ───────────────────────────────────────────────────────────────────
38
+
39
+ /** Matches #include <file> and #include "file" (and #tryinclude variants). */
40
+ const RE_INCLUDE = /^[ \t]*#(?:try)?include[ \t]+([<"])([^>"]+)[>"][ \t]*(?:\/\/.*)?$/gm;
41
+
42
+ /** Matches #if defined <GUARD> — the start of an include guard block. */
43
+ const RE_IF_DEFINED = /^[ \t]*#if[ \t]+defined[ \t]+(\w+)/m;
44
+
45
+ /** Matches #define <GUARD> — the companion define of an include guard. */
46
+ const RE_DEFINE = (guard) => new RegExp(
47
+ '^[ \\t]*#define[ \\t]+' + guard.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
48
+ 'm'
49
+ );
50
+
51
+ // ─── IncludeGraph — bidirectional graph of #include relationships ────────────
52
+
53
+ class IncludeGraph {
54
+ constructor() {
55
+ /** Map<absPath, IncludeNode> */
56
+ this.nodes = new Map();
57
+
58
+ /** absPath of all discovered .sma root files */
59
+ this.roots = [];
60
+
61
+ /**
62
+ * Ordered list of directories and their labels for resolving
63
+ * <angle> includes (and "quoted" fallback).
64
+ * @type {{ path: string, label: string }[]}
65
+ */
66
+ this.includeDirs = [];
67
+ }
68
+
69
+ // ── Node management ──────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Get or create a node for an absolute path.
73
+ * @param {string} absPath
74
+ * @param {object} [meta] - Optional metadata (name, source, type, guard, error)
75
+ * @returns {IncludeNode}
76
+ */
77
+ node(absPath, meta) {
78
+ let n = this.nodes.get(absPath);
79
+ if (!n) {
80
+ n = new IncludeNode(absPath, meta);
81
+ this.nodes.set(absPath, n);
82
+ }
83
+ if (meta) {
84
+ if (meta.name != null) n.name = meta.name;
85
+ if (meta.source != null) n.source = meta.source;
86
+ if (meta.type != null) n.type = meta.type;
87
+ if (meta.guard != null) n.guard = meta.guard;
88
+ if (meta.error != null) n.error = meta.error;
89
+ }
90
+ return n;
91
+ }
92
+
93
+ /**
94
+ * Add a directed edge: fromFile includes toFile.
95
+ * Also stores how the include was spelled (<name> or "name").
96
+ */
97
+ addEdge(fromPath, toPath, { name, isAngle } = {}) {
98
+ const from = this.node(fromPath);
99
+ const to = this.node(toPath);
100
+ const display = isAngle ? `<${name}>` : `"${name}"`;
101
+ // Avoid duplicates
102
+ if (!from.includes.some(e => e.absPath === toPath)) {
103
+ from.includes.push({ absPath: toPath, display });
104
+ to.includedBy.push({ absPath: fromPath, display });
105
+ }
106
+ }
107
+
108
+ // ── Include guard detection ──────────────────────────────────────────────
109
+
110
+ /**
111
+ * Detect an include guard in a file's content.
112
+ * Returns the guard symbol or null.
113
+ *
114
+ * Pattern:
115
+ * #if defined <GUARD>
116
+ * #endinput
117
+ * #endif
118
+ * #define <GUARD>
119
+ */
120
+ static detectGuard(content) {
121
+ const m = content.match(RE_IF_DEFINED);
122
+ if (!m) return null;
123
+ const guard = m[1];
124
+ // #endinput must be present — signals "stop reading here"
125
+ if (!content.includes('#endinput')) return null;
126
+ // #define <GUARD> must exist somewhere after the #if block
127
+ if (!RE_DEFINE(guard).test(content)) return null;
128
+ return guard;
129
+ }
130
+
131
+ // ── Include parsing ──────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Extract #include/#tryinclude directives from file content.
135
+ * @param {string} content
136
+ * @returns {{ name: string, isAngle: boolean }[]}
137
+ */
138
+ static parseIncludesFromContent(content) {
139
+ const result = [];
140
+
141
+ // Use a fresh regex instance per call to avoid state leakage
142
+ const re = new RegExp(RE_INCLUDE.source, 'gm');
143
+ let m;
144
+ while ((m = re.exec(content)) !== null) {
145
+ result.push({ name: m[2].trim(), isAngle: m[1] === '<' });
146
+ }
147
+ return result;
148
+ }
149
+
150
+ /**
151
+ * Read and parse includes from a file on disk.
152
+ * @param {string} absPath
153
+ * @returns {{ name: string, isAngle: boolean }[]}
154
+ */
155
+ static readIncludes(absPath) {
156
+ let content;
157
+ try {
158
+ content = fs.readFileSync(absPath, 'utf8');
159
+ } catch {
160
+ return [];
161
+ }
162
+ return IncludeGraph.parseIncludesFromContent(content);
163
+ }
164
+
165
+ // ── Include resolution ───────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Resolve an #include name to an absolute file path.
169
+ *
170
+ * Resolution order mirrors amxxpc:
171
+ * 1. "quoted" includes — check the including file's own directory first
172
+ * 2. <angle> or fallback — search includeDirs in order
173
+ * 3. Case-insensitive fallback on all of the above
174
+ *
175
+ * @param {string} fromFile - Absolute path of the file doing the #include
176
+ * @param {string} name - The bare filename from the directive (<X> or "X")
177
+ * @param {boolean} isAngle - Whether the include was <angle> style
178
+ * @returns {{ absPath: string, source: string|null } | null}
179
+ */
180
+ resolveInclude(fromFile, name, isAngle) {
181
+ const withExt = /\.inc$/i.test(name) ? name : name + '.inc';
182
+
183
+ // ── 1. "quoted" — check the including file's own directory ──
184
+ if (!isAngle) {
185
+ const rel = path.resolve(path.dirname(fromFile), withExt);
186
+ if (fs.existsSync(rel)) {
187
+ return { absPath: rel, source: null };
188
+ }
189
+ }
190
+
191
+ // ── 2. Search include dirs (exact match first) ──
192
+ for (const dir of this.includeDirs) {
193
+ const full = path.join(dir.path, withExt);
194
+ if (fs.existsSync(full)) {
195
+ return { absPath: full, source: dir.label };
196
+ }
197
+ }
198
+
199
+ // ── 3. "quoted" — case-insensitive fallback in file's own dir ──
200
+ if (!isAngle) {
201
+ const ci = findCaseInsensitive(path.dirname(fromFile), withExt);
202
+ if (ci) return { absPath: ci, source: null };
203
+ }
204
+
205
+ // ── 4. Case-insensitive fallback in include dirs ──
206
+ for (const dir of this.includeDirs) {
207
+ const ci = findCaseInsensitive(dir.path, withExt);
208
+ if (ci) return { absPath: ci, source: dir.label };
209
+ }
210
+
211
+ return null; // unresolvable
212
+ }
213
+
214
+ // ── Graph building ───────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Recursively parse a file and all its #include'd files.
218
+ *
219
+ * @param {string} absPath - Absolute path to parse
220
+ * @param {Set<string>} guards - Active include guards on the current path
221
+ * @param {Set<string>} [stack] - Recursion stack for cycle detection (per-root)
222
+ * @param {number} depth - Safety limit
223
+ */
224
+ parseFile(absPath, guards = new Set(), stack = new Set(), depth = 0) {
225
+ if (depth > 500) return;
226
+ if (!fs.existsSync(absPath)) return;
227
+
228
+ const n = this.node(absPath);
229
+
230
+ // Read content and detect guard if not already known
231
+ let content;
232
+ try {
233
+ content = fs.readFileSync(absPath, 'utf8');
234
+ } catch {
235
+ n.error = 'unreadable';
236
+ return;
237
+ }
238
+
239
+ if (!n.guard) {
240
+ n.guard = IncludeGraph.detectGuard(content);
241
+ }
242
+
243
+ // Guard already set on this path → don't re-expand
244
+ if (n.guard && guards.has(n.guard)) return;
245
+
246
+ // File already in current recursion stack → cycle without guards.
247
+ // The edge is already recorded (addEdge below), just don't expand
248
+ // children to avoid infinite recursion.
249
+ if (stack.has(absPath)) return;
250
+
251
+ if (n.guard) guards.add(n.guard);
252
+ stack.add(absPath);
253
+
254
+ // Parse and recurse into includes
255
+ const includes = IncludeGraph.parseIncludesFromContent(content);
256
+ for (const inc of includes) {
257
+ const resolved = this.resolveInclude(absPath, inc.name, inc.isAngle);
258
+ if (resolved) {
259
+ // Ensure target node exists (with a fallback name)
260
+ this.node(resolved.absPath, {
261
+ name: path.basename(resolved.absPath),
262
+ source: resolved.source || n.source,
263
+ });
264
+ this.addEdge(absPath, resolved.absPath, inc);
265
+ // Siblings share guards: a sibling that sets guard G must be visible
266
+ // to the next sibling (same compile unit). Do NOT clone `guards`.
267
+ this.parseFile(resolved.absPath, guards, stack, depth + 1);
268
+ }
269
+ }
270
+
271
+ stack.delete(absPath);
272
+ }
273
+
274
+ /**
275
+ * Build the full graph by scanning all root .sma files.
276
+ * Must be called after roots and includeDirs are populated.
277
+ */
278
+ build() {
279
+ for (const root of this.roots) {
280
+ this.parseFile(root, new Set(), new Set());
281
+ }
282
+ }
283
+
284
+ // ── Tree traversal ───────────────────────────────────────────────────────
285
+
286
+ /**
287
+ * Walk DOWN from a node: file → its #includes → their #includes, etc.
288
+ * Tracks include guards per path so guarded files are not re-expanded.
289
+ * Uses recursion-stack cycle detection so circular includes without
290
+ * guards don't loop infinitely.
291
+ *
292
+ * @param {string} absPath
293
+ * @param {Set<string>} [guards]
294
+ * @param {Set<string>} [stack] - Recursion stack for cycle detection
295
+ * @returns {object|null} Tree node data
296
+ */
297
+ walkDown(absPath, guards = new Set(), stack = new Set()) {
298
+ const n = this.nodes.get(absPath);
299
+ if (!n) return null;
300
+
301
+ const isGuarded = !!(n.guard && guards.has(n.guard));
302
+ const inCycle = stack.has(absPath);
303
+
304
+ const tree = {
305
+ name: n.name,
306
+ absPath,
307
+ source: n.source,
308
+ guard: n.guard,
309
+ isGuarded,
310
+ cycle: inCycle && !isGuarded, // cycle detected (without guard)
311
+ error: n.error,
312
+ children: [],
313
+ };
314
+
315
+ if (isGuarded || n.error || inCycle) return tree;
316
+
317
+ if (n.guard) guards.add(n.guard);
318
+ stack.add(absPath);
319
+
320
+ for (const edge of n.includes) {
321
+ // Siblings share guards (same compile unit). Do NOT clone.
322
+ const child = this.walkDown(edge.absPath, guards, stack);
323
+ if (child) {
324
+ child._display = edge.display;
325
+ tree.children.push(child);
326
+ }
327
+ }
328
+
329
+ stack.delete(absPath);
330
+ return tree;
331
+ }
332
+
333
+ /**
334
+ * Walk UP from a node: file → files that #include it → their includers, etc.
335
+ * Uses per-path cycle detection to prevent infinite loops.
336
+ *
337
+ * @param {string} absPath
338
+ * @param {Set<string>} [pathVisited]
339
+ * @returns {object|null} Tree node data
340
+ */
341
+ walkUp(absPath, pathVisited = new Set()) {
342
+ if (pathVisited.has(absPath)) return null;
343
+ pathVisited.add(absPath);
344
+
345
+ const n = this.nodes.get(absPath);
346
+ if (!n) return null;
347
+
348
+ const tree = {
349
+ name: n.name,
350
+ absPath,
351
+ source: n.source,
352
+ guard: n.guard,
353
+ isGuarded: false,
354
+ error: n.error,
355
+ includedBy: [],
356
+ };
357
+
358
+ for (const edge of n.includedBy) {
359
+ const parent = this.walkUp(edge.absPath, new Set(pathVisited));
360
+ if (parent) {
361
+ parent._display = edge.display; // how this file was included by parent
362
+ tree.includedBy.push(parent);
363
+ }
364
+ }
365
+
366
+ return tree;
367
+ }
368
+
369
+ /**
370
+ * Resolve a target path to a node in the graph.
371
+ * If the path is not already in the graph, tries to add it as a standalone node.
372
+ *
373
+ * @param {string} targetPath
374
+ * @returns {string} Resolved absolute path
375
+ */
376
+ resolveTarget(targetPath) {
377
+ const abs = path.resolve(targetPath);
378
+ if (this.nodes.has(abs)) return abs;
379
+
380
+ // Try to add the file if it exists
381
+ if (fs.existsSync(abs)) {
382
+ const name = path.basename(abs);
383
+ const type = abs.endsWith('.sma') ? 'sma' : 'inc';
384
+ this.node(abs, { name, type, source: null });
385
+ }
386
+
387
+ return abs;
388
+ }
389
+ }
390
+
391
+ // ─── IncludeNode ─────────────────────────────────────────────────────────────
392
+
393
+ class IncludeNode {
394
+ constructor(absPath, meta = {}) {
395
+ this.absPath = absPath;
396
+ this.name = meta.name || path.basename(absPath);
397
+ this.source = meta.source || null;
398
+ this.type = meta.type || (absPath.endsWith('.sma') ? 'sma' : 'inc');
399
+ this.guard = meta.guard || null;
400
+ this.error = meta.error || null;
401
+
402
+ /** @type {{ absPath: string, display: string }[]} */
403
+ this.includes = [];
404
+
405
+ /** @type {{ absPath: string, display: string }[]} */
406
+ this.includedBy = [];
407
+ }
408
+ }
409
+
410
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
411
+
412
+ /**
413
+ * Case-insensitive file search inside a directory, walking nested path segments
414
+ * (readdirSync only lists one level, so "SubDir/File.inc" needs per-segment lookup).
415
+ * Returns the first match (by readdir order) or null.
416
+ */
417
+ function findCaseInsensitive(dir, filename) {
418
+ try {
419
+ const segments = filename.split(/[\\/]/);
420
+ let current = dir;
421
+ for (let i = 0; i < segments.length; i++) {
422
+ const lower = segments[i].toLowerCase();
423
+ if (i === segments.length - 1) {
424
+ for (const entry of fs.readdirSync(current)) {
425
+ if (entry.toLowerCase() === lower) return path.join(current, entry);
426
+ }
427
+ return null;
428
+ }
429
+ let found = null;
430
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
431
+ if (entry.isDirectory() && entry.name.toLowerCase() === lower) {
432
+ found = entry.name;
433
+ break;
434
+ }
435
+ }
436
+ if (!found) return null;
437
+ current = path.join(current, found);
438
+ }
439
+ return null;
440
+ } catch (_) { return null; }
441
+ }
442
+
443
+ /**
444
+ * Depth-limit check: if depth is positive and current >= depth, stop.
445
+ */
446
+ function reachedDepth(depth, current) {
447
+ return depth > 0 && current >= depth;
448
+ }
449
+
450
+ /**
451
+ * Format a tree node (from walkDown/walkUp) as text using Unicode box-drawing.
452
+ *
453
+ * @param {object} tree - Tree data from walkDown or walkUp
454
+ * @param {'down'|'up'} direction
455
+ * @param {number} [maxDepth]
456
+ * @returns {string}
457
+ */
458
+ function renderTreeText(tree, direction, maxDepth) {
459
+ const lines = [];
460
+
461
+ const header = direction === 'up'
462
+ ? `Include tree (UP) for ${tree.name}`
463
+ : `Include tree (DOWN) for ${tree.name}`;
464
+
465
+ lines.push('=== ' + header + ' ===');
466
+ lines.push('');
467
+
468
+ // Root line
469
+ const rootInfo = formatNodeInfo(tree, '');
470
+ lines.push(tree.name + rootInfo);
471
+
472
+ const children = direction === 'up' ? tree.includedBy : tree.children;
473
+
474
+ if (children && children.length > 0) {
475
+ for (let i = 0; i < children.length; i++) {
476
+ renderSubtree(children[i], '', i === children.length - 1, direction, maxDepth, 1, lines);
477
+ }
478
+ } else if (direction === 'up') {
479
+ lines.push(' (no .sma files include this file)');
480
+ } else {
481
+ lines.push(' (no #include directives)');
482
+ }
483
+
484
+ // Legend
485
+ lines.push('');
486
+ lines.push('Legend:');
487
+ lines.push(' <file> — angle include (#include <file>)');
488
+ lines.push(' "file" — local include (#include "file")');
489
+ lines.push(' [guard] — include guard detected, expansion skipped');
490
+
491
+ return lines.join('\n');
492
+ }
493
+
494
+ function renderSubtree(node, prefix, isLast, direction, maxDepth, depth, lines) {
495
+ if (reachedDepth(maxDepth, depth)) {
496
+ lines.push(prefix + (isLast ? '└── ' : '├── ') + '… (max depth)');
497
+ return;
498
+ }
499
+
500
+ const connector = isLast ? '└── ' : '├── ';
501
+
502
+ // Display name: use _display if available (the spelling from the parent)
503
+ let displayName = node._display || node.name;
504
+ const info = formatNodeInfo(node, displayName);
505
+
506
+ lines.push(prefix + connector + displayName + info);
507
+
508
+ const children = direction === 'up' ? node.includedBy : node.children;
509
+ if (children && children.length > 0) {
510
+ const childPrefix = prefix + (isLast ? ' ' : '│ ');
511
+ for (let i = 0; i < children.length; i++) {
512
+ renderSubtree(children[i], childPrefix, i === children.length - 1, direction, maxDepth, depth + 1, lines);
513
+ }
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Format node metadata suffix (source, guard/error/not-found annotation).
519
+ */
520
+ function formatNodeInfo(node, displayName) {
521
+ const parts = [];
522
+
523
+ if (node.source) {
524
+ parts.push(node.source);
525
+ }
526
+
527
+ if (node.cycle) {
528
+ parts.push('cycle');
529
+ } else if (node.isGuarded) {
530
+ parts.push('guard already set');
531
+ } else if (node.guard) {
532
+ parts.push('guard: ' + node.guard);
533
+ }
534
+
535
+ if (node.error) {
536
+ parts.push('ERROR: ' + node.error);
537
+ }
538
+
539
+ if (parts.length === 0) return '';
540
+ return ' [' + parts.join(', ') + ']';
541
+ }
542
+
543
+ // ─── Public API ──────────────────────────────────────────────────────────────
544
+
545
+ /**
546
+ * Build a directed graph of #include relationships for an AMXX project and
547
+ * produce a tree starting from the given target file.
548
+ *
549
+ * @param {string} manifestPath - Path to amxbuild.yml (auto-detects if directory)
550
+ * @param {string} targetPath - Path to the .sma or .inc file to build tree from
551
+ * @param {object} [options]
552
+ * @param {'down'|'up'|'auto'} [options.direction='auto']
553
+ * - 'down': show everything the target file #includes (transitively)
554
+ * - 'up': show everything that #includes the target file (transitively)
555
+ * - 'auto': 'down' for .sma, 'up' for .inc
556
+ * @param {number} [options.depth=0] - Max depth (0 = unlimited)
557
+ * @param {string} [options.token] - GitHub PAT
558
+ * @param {boolean} [options.noFetch=false] - Skip network, use cache only
559
+ * @param {'text'|'json'} [options.format='text']
560
+ * @returns {Promise<{ text: string, tree: object, graph: IncludeGraph }>}
561
+ */
562
+ async function buildIncludeTree(manifestPath, targetPath, options = {}) {
563
+ const direction = options.direction || 'auto';
564
+ const depth = options.depth || 0;
565
+ const token = options.token || null; // explicit override; otherwise per-owner via manifest
566
+ const noFetch = options.noFetch !== undefined ? !!options.noFetch : false;
567
+ const format = options.format || 'text';
568
+
569
+ // ── 1. Resolve manifest ──────────────────────────────────────────────
570
+ const { path: mPath } = resolveManifestPath(manifestPath);
571
+ loadEnv(mPath); // .env tokens (GITHUB_TOKEN etc.)
572
+ const manifest = parseManifest(mPath);
573
+ const manifestDir = path.dirname(mPath);
574
+
575
+ const tokenFor = (repo) => token || resolveGithubToken(manifest, repo);
576
+
577
+ // Resolve refs for every manifest repo once (same single-source loop as the
578
+ // build pipeline); a repo whose ref resolution throws keeps _resolvedRef
579
+ // undefined and is skipped below (ref-less repos still clone default branch).
580
+ await Promise.all(manifest.repos.map(async (repoConfig) => {
581
+ try {
582
+ await resolveRepoRefs([repoConfig], tokenFor);
583
+ } catch (_) { /* ref failed — repo skipped by the fetch loops below */ }
584
+ }));
585
+
586
+ // ── 2. Create graph ──────────────────────────────────────────────────
587
+ const graph = new IncludeGraph();
588
+
589
+ // ── 3. Build include directories list ────────────────────────────────
590
+ // Dep includes come BEFORE the stdlib, matching the real build's search
591
+ // order (src/commands/build.js: deps first, then the compiler bundle).
592
+
593
+ // 3a. Dependency includes — globalDeps + per-repo DEPS_LIST/deps_override
594
+ const depEntries = [...manifest.globalDeps];
595
+ for (const repoConfig of manifest.repos) {
596
+ if (repoConfig.deps_override) {
597
+ depEntries.push(...repoConfig.deps_override);
598
+ continue;
599
+ }
600
+ if (repoConfig._resolvedRef === undefined) continue; // ref failed → skip
601
+ try {
602
+ const repoDir = await fetchRepo(repoConfig.repo, repoConfig._resolvedRef, tokenFor(repoConfig.repo), noFetch, manifest.github.ssh);
603
+ const depsPath = path.join(repoDir, 'DEPS_LIST');
604
+ if (fs.existsSync(depsPath)) {
605
+ depEntries.push(...parseDepsLines(fs.readFileSync(depsPath, 'utf8').split(/\r?\n/)));
606
+ }
607
+ } catch (_) { /* skip unresolvable repos */ }
608
+ }
609
+
610
+ const seenDeps = new Set();
611
+ for (const dep of depEntries) {
612
+ const key = `${normalize(dep.repo)}@${dep.ref}`;
613
+ if (seenDeps.has(key)) continue;
614
+ seenDeps.add(key);
615
+ try {
616
+ const depDir = await fetchDepIncludeDir(dep, tokenFor(dep.repo), noFetch, manifest.github.ssh);
617
+ graph.includeDirs.push({ path: depDir, label: `dep: ${dep.repo}@${dep.ref}` });
618
+ } catch (_) { /* skip unresolvable */ }
619
+ }
620
+
621
+ // 3b. Standard AMXX includes (compiler bundle)
622
+ const amxVersion = manifest.amxmodx.version || await fetchLatestVersion();
623
+ try {
624
+ const { includeDir } = await fetchCompiler(amxVersion);
625
+ if (includeDir) {
626
+ graph.includeDirs.push({ path: includeDir, label: 'AMXX stdlib ' + amxVersion });
627
+ }
628
+ } catch (_) { /* compiler not available */ }
629
+
630
+ // 3c. Local scripting/ and scripting/include/ — added per-root later
631
+
632
+ // ── 4. Find all root .sma files ──────────────────────────────────────
633
+
634
+ // 4a. Local scripting/
635
+ const localScripting = path.join(manifestDir, manifest.amxmodx.dir, 'scripting');
636
+ const localIncDir = path.join(localScripting, 'include');
637
+ const localLabel = 'local ' + path.relative(manifestDir, localScripting);
638
+
639
+ if (fs.existsSync(localScripting)) {
640
+ // Add scripting/ and scripting/include/ to include dirs
641
+ // for <angle> includes from any file in this project
642
+ graph.includeDirs.push({ path: localScripting, label: localLabel });
643
+ if (fs.existsSync(localIncDir)) {
644
+ graph.includeDirs.push({ path: localIncDir, label: localLabel + '/include' });
645
+ }
646
+
647
+ // Find .sma files
648
+ const localSmas = await glob('**/*.sma', { cwd: localScripting, dot: false });
649
+ for (const rel of localSmas) {
650
+ const abs = path.resolve(localScripting, rel);
651
+ graph.node(abs, {
652
+ name: rel,
653
+ source: localLabel,
654
+ type: 'sma',
655
+ });
656
+ graph.roots.push(abs);
657
+ }
658
+ }
659
+
660
+ // 4b. Repo scripting/ dirs
661
+ for (const repoConfig of manifest.repos) {
662
+ if (repoConfig._resolvedRef === undefined) continue; // ref failed → skip
663
+ let repoDir;
664
+ try {
665
+ repoDir = await fetchRepo(repoConfig.repo, repoConfig._resolvedRef, tokenFor(repoConfig.repo), noFetch, manifest.github.ssh);
666
+ } catch (_) {
667
+ continue; // skip repos that can't be fetched
668
+ }
669
+
670
+ const scriptingDir = path.join(repoDir, repoConfig.amxmodx_dir, 'scripting');
671
+ const incDir = path.join(scriptingDir, 'include');
672
+ const repoLabel = 'repo: ' + repoConfig.repo;
673
+
674
+ if (fs.existsSync(scriptingDir)) {
675
+ // Add scripting/ and scripting/include/ to include dirs
676
+ graph.includeDirs.push({ path: scriptingDir, label: repoLabel });
677
+ if (fs.existsSync(incDir)) {
678
+ graph.includeDirs.push({ path: incDir, label: repoLabel + '/include' });
679
+ }
680
+
681
+ const smas = await glob('**/*.sma', { cwd: scriptingDir, dot: false });
682
+ for (const rel of smas) {
683
+ const abs = path.resolve(scriptingDir, rel);
684
+ graph.node(abs, {
685
+ name: rel,
686
+ source: repoLabel,
687
+ type: 'sma',
688
+ });
689
+ graph.roots.push(abs);
690
+ }
691
+ }
692
+ }
693
+
694
+ // ── 5. Build the full graph from all roots ───────────────────────────
695
+ graph.build();
696
+
697
+ // ── 6. Determine direction and walk tree from target ─────────────────
698
+ const targetAbs = graph.resolveTarget(targetPath);
699
+ const treeDir = direction === 'auto'
700
+ ? (targetAbs.endsWith('.sma') ? 'down' : 'up')
701
+ : direction;
702
+
703
+ let tree;
704
+ if (treeDir === 'up') {
705
+ tree = graph.walkUp(targetAbs);
706
+ } else {
707
+ tree = graph.walkDown(targetAbs);
708
+ }
709
+
710
+ if (!tree) {
711
+ const msg = `Target file "${targetPath}" does not exist or could not be read.`;
712
+ return {
713
+ text: msg,
714
+ tree: null,
715
+ graph,
716
+ };
717
+ }
718
+
719
+ // ── 7. Format output ─────────────────────────────────────────────────
720
+ const text = format === 'json'
721
+ ? JSON.stringify(tree, null, 2)
722
+ : renderTreeText(tree, treeDir, depth);
723
+
724
+ return { text, tree, graph };
725
+ }
726
+
727
+ // ─── Internal helpers ────────────────────────────────────────────────────────
728
+
729
+ /**
730
+ * Parse a single preprocessor include directive into { filename, localFirst }.
731
+ *
732
+ * Accepted forms:
733
+ * #include <file> — global search only (<> equivalent)
734
+ * #include "file" — local (sma dir) first, then global
735
+ * #include file — bare filename, equivalent to <>
736
+ * <file>, "file", file — directive prefix is optional
737
+ *
738
+ * Extension defaults to .inc if missing.
739
+ * Shared by the MCP resolve_include tool. The IncludeGraph's regex-based
740
+ * parseIncludesFromContent stays the engine for bulk content parsing.
741
+ */
742
+ function parseIncludeDirective(raw) {
743
+ let input = String(raw || '').trim();
744
+ if (!input) throw new Error('Empty include directive');
745
+
746
+ input = input.replace(/^#include\s+/, '');
747
+
748
+ let localFirst = false;
749
+
750
+ if (input.startsWith('"') && input.endsWith('"')) {
751
+ input = input.slice(1, -1);
752
+ localFirst = true;
753
+ } else if (input.startsWith('<') && input.endsWith('>')) {
754
+ input = input.slice(1, -1);
755
+ }
756
+
757
+ if (!path.extname(input)) input += '.inc';
758
+
759
+ return { filename: input, localFirst };
760
+ }
761
+
762
+ /**
763
+ * Search an ordered list of { path, label } targets for a file.
764
+ * Exact match first per target, then case-insensitive fallback (via
765
+ * findCaseInsensitive).
766
+ *
767
+ * Local-first resolution is expressed by ordering `dirs` — the caller pushes
768
+ * the local dir first when the directive was "quoted".
769
+ *
770
+ * @param {{ path: string, label: string }[]} dirs - search targets in priority order
771
+ * @param {string} name - bare filename (may include subdirectories)
772
+ * @returns {{ foundPath: string, label: string } | null}
773
+ */
774
+ function searchIncludeFile(dirs, name) {
775
+ for (const { path: sp, label } of dirs) {
776
+ const exact = path.join(sp, name);
777
+ if (fs.existsSync(exact)) return { foundPath: exact, label };
778
+ const ci = findCaseInsensitive(sp, name);
779
+ if (ci) return { foundPath: ci, label };
780
+ }
781
+ return null;
782
+ }
783
+
784
+ /**
785
+ * Collect all .inc files under a directory, sorted, as { rel, abs } entries.
786
+ * Shared glob (dot:false, all levels) used by the MCP dep-interface tools and
787
+ * the include collection in the build pipeline.
788
+ *
789
+ * @param {string} srcDir - directory to scan
790
+ * @returns {Promise<{ rel: string, abs: string }[]>}
791
+ */
792
+ async function collectIncFiles(srcDir) {
793
+ const entries = await glob('**/*.inc', { cwd: srcDir, dot: false });
794
+ entries.sort();
795
+ return entries.map((rel) => ({ rel, abs: path.join(srcDir, rel) }));
796
+ }
797
+
798
+ /**
799
+ * Fetch a dependency's include directory, using cache where possible.
800
+ * Public single-source-of-truth shared by the build pipeline (include-tree),
801
+ * the CLI and the MCP layer.
802
+ *
803
+ * Explicit-include_path semantics: silently falls back to the repo root when
804
+ * the given path does not exist (the MCP callers rely on this).
805
+ *
806
+ * @param {object} dep - { repo, ref, include_path, source, asset }
807
+ * @param {string|null} token - GitHub PAT (per-owner resolved by the caller)
808
+ * @param {boolean} [noFetch=false] - only use cache, skip network
809
+ * @param {boolean} [ssh=false] - clone via SSH
810
+ * @returns {Promise<string>} directory to use as the include dir
811
+ */
812
+ async function fetchDepIncludeDir(dep, token, noFetch, ssh = false) {
813
+ if (dep.source === 'release') {
814
+ return fetchReleaseDep(
815
+ { repo: dep.repo, ref: dep.ref, include_path: dep.include_path, asset: dep.asset },
816
+ token,
817
+ noFetch
818
+ );
819
+ }
820
+
821
+ const resolvedRef = await resolveRefIfLatest(dep.ref, dep.repo, token);
822
+ const repoDir = await fetchRepo(dep.repo, resolvedRef, token, noFetch, ssh);
823
+ const candidates = dep.include_path
824
+ ? [dep.include_path]
825
+ : ['scripting/include', 'amxmodx/scripting/include', 'include', '.'];
826
+
827
+ for (const candidate of candidates) {
828
+ const full = path.join(repoDir, candidate);
829
+ if (fs.existsSync(full)) return full;
830
+ }
831
+
832
+ return repoDir;
833
+ }
834
+
835
+ // ─── Exports ─────────────────────────────────────────────────────────────────
836
+
837
+ module.exports = {
838
+ buildIncludeTree,
839
+ IncludeGraph,
840
+ findCaseInsensitive,
841
+ fetchDepIncludeDir,
842
+ parseIncludeDirective,
843
+ searchIncludeFile,
844
+ collectIncFiles,
845
+ };