All files / PARSE/1-Discover IncludeResolver.ts

96.99% Statements 129/133
91.04% Branches 61/67
100% Functions 16/16
98.42% Lines 125/127

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675                                    48x                                                                                                                                                                                                                                                                               682x                                                                                             682x           682x 682x 682x 682x                       684x                             684x   684x 371x 371x 370x               684x 371x 175x                 196x       684x                     370x 370x 370x 370x       370x 370x 7x     370x 80x 80x     290x               556x                                 370x 186x 186x       186x                     290x     290x 3x   287x   287x 287x   287x                       287x 137x 137x   137x       137x 68x   137x     150x 150x             150x   150x   150x             150x       150x       17x                               163x                                     80x 55x     80x     80x   40x 80x                   1x             1x 1x               18x                               143x 143x               143x 5x 5x     138x                                                                         486x 486x 486x 486x 486x 486x   486x       144x   144x 143x   143x           143x   138x 138x 138x   138x 138x   138x 144x   144x 48x           48x       48x 30x 3x         30x     18x 18x   18x 18x   18x 48x       486x 126x     486x 486x   486x 486x 139x 139x 138x       486x                                                                 647x     647x     647x 647x 157x 157x 471x 471x 158x           647x          
import { dirname, join, resolve } from "node:path";
 
import CNextMarkerDetector from "./CNextMarkerDetector";
import IncludeDirectiveText from "../../utils/IncludeDirectiveText";
import IncludeDiscovery from "./IncludeDiscovery";
import IncludeRewriter from "../../utils/IncludeRewriter";
import FileDiscovery from "./FileDiscovery";
import type THeaderExtension from "../../types/THeaderExtension";
import IDiscoveredFile from "./types/IDiscoveredFile";
import type IHeaderRoot from "./types/IHeaderRoot";
import EFileType from "./types/EFileType";
import DependencyGraph from "./DependencyGraph";
import IFileSystem from "../../types/IFileSystem";
 
/**
 * The header extensions ADR-010 admits, and the C-Next source each names:
 * E0504 asks whether `ext.cnx` is where an include of `ext.h` would find it.
 */
const ADMITTED_HEADER = /\.(h|hpp)$/i;
 
/**
 * Result of resolving includes from source content
 */
interface IResolvedIncludes {
  /** C/C++ headers to parse for symbol collection */
  headers: IDiscoveredFile[];
 
  /** C-Next files to parse for symbol collection */
  cnextIncludes: IDiscoveredFile[];
 
  /** Warnings for unresolved local includes */
  warnings: string[];
 
  /**
   * Whether any include brings in names this transpiler cannot see -- a C/C++
   * header, or an include that resolved to nothing and does not name C-Next
   * source (an unresolved `<system.h>` is silently ignored, and its names still
   * exist at compile time; an unresolved `.cnx` supplies no C or C++ names).
   *
   * #1399 review: consumed by the E0426/E0427 precondition. Recorded HERE
   * because this class already decides what each directive IS; the analyzer's
   * own attempt re-derived it from `#include` token text, became a third
   * spelling of "is this a C-Next include?", and was the one that missed
   * `.cnext`. `headers` alone cannot answer it -- `<stdio.h>` resolves to
   * nothing on most systems and so appears in no list at all, which is exactly
   * how `FILE` slipped through.
   */
  hasForeignInclude: boolean;
 
  /**
   * Issue #497: Map from resolved header path to original include directive.
   * Used to include C headers (instead of forward declarations) when their
   * types are used in public interfaces.
   * Example: "/abs/path/data-types.h" => '#include "data-types.h"'
   */
  headerIncludeDirectives: Map<string, string>;
 
  /**
   * Issue #1467: for each `.cnx` include, the author's spelling mapped to the
   * path the generated header is actually reachable at, relative to the header
   * output root -- e.g. `"utils.cnx"` => `"Display/utils.h"`.
   *
   * Recorded here because this class is where an include's spelling and its
   * RESOLVED file are both in hand; every consumer downstream has one or the
   * other. The value comes from the owner injected as `headerIncludePathFor`,
   * never from the spelling: those were derived independently in three places,
   * and agreed only because all three copied what the author typed.
   */
  cnextIncludeRewrites: Map<string, string>;
 
  /**
   * #1725: for each quoted include that resolved beside the file that wrote
   * it -- so its spelling is relative to that file -- the absolute file the
   * spelling names: the header, or the generated header beside a `.cnx` whose
   * header the output root does not reach. Keyed like
   * `headerIncludeDirectives`.
   *
   * Another file that needs the same header spells it relative to ITSELF from
   * this. Copying the spelling gave `src/main.h` lib/a.cnx's `"dev.h"`, which
   * names `src/dev.h` from there. A spelling that resolved along the search
   * path is not recorded: it is valid wherever that path is, and re-spelling it
   * relative to a file would climb into an SDK or a library directory.
   */
  writerRelativeIncludes: Map<string, string>;
 
  /**
   * #1672: the file every directive this resolver read resolved to, or null,
   * keyed by `IncludeDirectiveText.join`. 2.1's E0506 reads it rather
   * than resolving the include a second time.
   */
  resolutions: Map<string, string | null>;
 
  /**
   * #1672: for each include of a header ADR-010 admits whose C-Next source
   * the same form of include would find, that source's spelling (`ext.cnx`
   * for `ext.h`), keyed like `resolutions`. 2.1's E0504 reads it.
   */
  cnextAlternatives: Map<string, string>;
 
  /**
   * The kind of file each directive's spelling names, by its extension, keyed
   * like `resolutions` (#1444, owner ruling 1). The one classification of an
   * include: 2.1's ADR-010 rules and render read it, and neither classifies a
   * spelling itself. A directive 1.2 parsed always has an entry.
   */
  kinds: Map<string, EFileType>;
 
  /**
   * The file's `.cnx` includes, each rendered as the include its generated
   * header carries: the author's directive with its path replaced by the
   * header `cnextIncludeRewrites` names, or by the extension swap when it
   * names none (#1467). In source order, the author's spacing and form kept.
   *
   * Issue #589 / #941: these define types used in function signatures, so the
   * generated header needs them. #1444: derived here, from the tokens this
   * resolver lexes, rather than from 1.2's tree in Stage 5.
   */
  userIncludes: string[];
 
  /**
   * The file's other includes -- every directive that does not name C-Next
   * source -- exactly as written, in source order.
   *
   * Issue #424: a macro from one of these can appear inside a generated
   * declaration -- `u32[DEVICE_COUNT] devices` becomes
   * `extern uint32_t devices[DEVICE_COUNT]`. Such a header only compiles for a
   * translation unit that already included the macro's source, so when the
   * generated header names a macro it must carry the include itself. Kept apart
   * from `userIncludes` because it is added conditionally: propagating every C
   * include into every header would put implementation-only dependencies into
   * the public interface. Issue #985's translation-unit recovery reads it too.
   */
  cHeaderIncludes: string[];
}
 
/**
 * Unified include resolution for the C-Next Pipeline
 *
 * This class encapsulates the complete include resolution workflow,
 * used by the unified `transpile()` entry point for both file and source modes.
 *
 * Key responsibilities:
 * - Extract #include directives from source content
 * - Resolve include paths using search directories
 * - Categorize resolved files into headers vs C-Next includes
 * - Track warnings for unresolved local includes
 * - Deduplicate resolved files by path
 *
 * @example
 * const resolver = new IncludeResolver(['/path/to/includes'], '.h', fs, null, '/path/to/src');
 * const result = resolver.resolve('#include "header.h"');
 * // result.headers contains resolved header files
 */
class IncludeResolver {
  private readonly resolvedPaths: Set<string> = new Set();
  private readonly fs: IFileSystem;
  private readonly headerExtension: THeaderExtension;
 
  /**
   * Issue #1467: asked where the generated header for a `.cnx` is reachable,
   * relative to the header output root. Null when the caller does not know
   * (no PathResolver yet) or the header lands outside that root; the author's
   * spelling is kept in that case, which is what every caller did before.
   */
  private readonly headerIncludePathFor:
    | ((cnxPath: string) => string | null)
    | null;
 
  /**
   * #1725: the directory a quoted include from the file being resolved
   * resolves beside (ADR-010). Required since #1835's review: its `null`
   * default served only tests, and with no directory a quoted C-Next include
   * was searched like any other -- a second rule beside #1672's one.
   */
  private readonly quotedIncludeDirectory: string;
 
  /**
   * @param headerIncludePathFor Issue #1467: where the generated header for a
   *   `.cnx` is reachable, relative to the header output root. See the field
   *   above for why it may be null.
   * @param quotedIncludeDirectory #1725: see the field above.
   * @param headerExtension The extension generated headers get in this run
   *   (".h" or ".hpp").
   *
   *   Issue #1319: this parameter was a mode with a `false` default, and the
   *   default was load-bearing in the wrong direction -- `IncludeTreeWalker`
   *   never passed it, so that instance answered ".h" for every C++ run. It
   *   went unnoticed because the walker returned only `cnextIncludes` and
   *   dropped the one field the extension feeds. #1319 let that caller pass
   *   `null`; #1435 deleted the walker, the one caller that read no directive,
   *   so the parameter is required and never null. With no default, no caller
   *   can inherit a wrong extension.
   *
   *   This used to be readable before the fact settled: cppDetected was raised
   *   by discovering a header, which could happen after IncludeResolver ran, so
   *   the extension here could be stale and HeaderGeneratorUtils absorbed the
   *   resulting .h/.hpp mismatch with stem-based dedup. #1319 made the mode
   *   declared, so it is known before any file is opened and there is no longer
   *   an early read to get wrong.
   */
  constructor(
    private readonly searchPaths: string[],
    headerExtension: THeaderExtension,
    fs: IFileSystem,
    headerIncludePathFor: ((cnxPath: string) => string | null) | null,
    quotedIncludeDirectory: string,
  ) {
    this.fs = fs;
    this.headerExtension = headerExtension;
    this.headerIncludePathFor = headerIncludePathFor;
    this.quotedIncludeDirectory = quotedIncludeDirectory;
  }
 
  /**
   * Extract includes from source content and resolve them to files
   *
   * @param content - A `.cnx` file's content. Its directives are read as the
   *   grammar reads them (#1745), so C header text does not belong here.
   * @param sourceFilePath - Optional path to source file (for error messages)
   * @returns Resolved includes categorized by type, plus warnings
   */
  resolve(content: string, sourceFilePath?: string): IResolvedIncludes {
    const result: IResolvedIncludes = {
      headers: [],
      cnextIncludes: [],
      warnings: [],
      headerIncludeDirectives: new Map<string, string>(),
      cnextIncludeRewrites: new Map<string, string>(),
      writerRelativeIncludes: new Map<string, string>(),
      resolutions: new Map<string, string | null>(),
      cnextAlternatives: new Map<string, string>(),
      kinds: new Map<string, EFileType>(),
      userIncludes: [],
      cHeaderIncludes: [],
      hasForeignInclude: false,
    };
 
    const directives = IncludeDiscovery.directiveTextsOf(content);
 
    for (const directive of directives) {
      const includeInfo = IncludeDirectiveText.split(directive);
      if (includeInfo !== null) {
        this._processInclude(includeInfo, sourceFilePath, result);
      }
    }
 
    // After every directive is resolved, so each `.cnx` include's header is
    // known. Issue #1467 review: one predicate for "is this a C-Next
    // include?" -- a substring test answered NO for `<utils.cnext>`. #1444:
    // and that predicate is the kind recorded above.
    for (const directive of directives) {
      if (IncludeRewriter.namesCNext(directive, result.kinds)) {
        result.userIncludes.push(
          IncludeRewriter.rewrite(
            directive,
            result.kinds,
            result.cnextIncludeRewrites,
            this.headerExtension,
          ),
        );
      } else {
        result.cHeaderIncludes.push(directive);
      }
    }
 
    return result;
  }
 
  /**
   * Process a single include directive
   */
  private _processInclude(
    includeInfo: { path: string; isLocal: boolean },
    sourceFilePath: string | undefined,
    result: IResolvedIncludes,
  ): void {
    const resolved = this._resolveSpelling(includeInfo);
    const directive = IncludeDirectiveText.join(includeInfo);
    result.resolutions.set(directive, resolved);
    result.kinds.set(
      directive,
      FileDiscovery.classifyFile(includeInfo.path).type,
    );
    const alternative = this._cnextAlternativeOf(includeInfo);
    if (alternative !== null) {
      result.cnextAlternatives.set(directive, alternative);
    }
 
    if (!resolved) {
      this._handleUnresolvedInclude(includeInfo, sourceFilePath, result);
      return;
    }
 
    this._handleResolvedInclude(resolved, includeInfo, result);
  }
 
  /** Where an include resolves from this file, by the one rule (#1672). */
  private _resolveSpelling(includeInfo: {
    path: string;
    isLocal: boolean;
  }): string | null {
    return IncludeDiscovery.resolveSpelling(
      includeInfo,
      this.quotedIncludeDirectory,
      this.searchPaths,
      this.fs,
    );
  }
 
  /**
   * For an include of a header ADR-010 admits, the C-Next source spelling
   * when the same form of include would find it -- E0504's question, which
   * 2.1 reads rather than asks (#1672).
   */
  private _cnextAlternativeOf(includeInfo: {
    path: string;
    isLocal: boolean;
  }): string | null {
    if (!ADMITTED_HEADER.test(includeInfo.path)) return null;
    const spelling = includeInfo.path.replace(ADMITTED_HEADER, ".cnx");
    const found = this._resolveSpelling({
      path: spelling,
      isLocal: includeInfo.isLocal,
    });
    return found === null ? null : spelling;
  }
 
  /**
   * Handle a resolved include path
   */
  private _handleResolvedInclude(
    resolved: string,
    includeInfo: { path: string; isLocal: boolean },
    result: IResolvedIncludes,
  ): void {
    const absolutePath = resolve(resolved);
 
    // Deduplicate by absolute path
    if (this.resolvedPaths.has(absolutePath)) {
      return;
    }
    this.resolvedPaths.add(absolutePath);
 
    const file = FileDiscovery.discoverFile(resolved, this.fs);
    Iif (!file) return;
 
    this._categorizeFile(file, absolutePath, includeInfo, result);
  }
 
  /**
   * Categorize a discovered file into headers or cnext includes
   */
  private _categorizeFile(
    file: IDiscoveredFile,
    absolutePath: string,
    includeInfo: { path: string; isLocal: boolean },
    result: IResolvedIncludes,
  ): void {
    if (file.type === EFileType.CHeader || file.type === EFileType.CppHeader) {
      result.headers.push(file);
      result.hasForeignInclude = true;
      // Issue #497: Track the original include directive for this header
      result.headerIncludeDirectives.set(
        absolutePath,
        IncludeDirectiveText.join(includeInfo),
      );
      if (this._resolvedBesideWriter(includeInfo, absolutePath)) {
        result.writerRelativeIncludes.set(absolutePath, absolutePath);
      }
      return;
    }
 
    Eif (file.type === EFileType.CNext) {
      result.cnextIncludes.push(file);
      // Issue #854: Track header directive for cnext includes so their types
      // can be mapped by ExternalTypeHeaderBuilder, preventing duplicate
      // forward declarations (MISRA Rule 5.6)
      // Issue #1467: ask the owner where the header is reachable. The
      // extension swap below is the fallback for a caller with no resolver
      // and for a header outside the output root -- not a second answer.
      const reachable = this.headerIncludePathFor?.(absolutePath) ?? null;
      const headerPath =
        reachable ??
        IncludeRewriter.besideSource(includeInfo.path, this.headerExtension);
      result.headerIncludeDirectives.set(
        absolutePath,
        IncludeDirectiveText.join({
          path: headerPath,
          isLocal: includeInfo.isLocal,
        }),
      );
      result.cnextIncludeRewrites.set(includeInfo.path, headerPath);
      // #1725: an output-root path is valid from every file. The author's
      // spelling is not: its header is generated beside the `.cnx`, so that
      // file is what another includer must spell relative to itself.
      if (
        reachable === null &&
        this._resolvedBesideWriter(includeInfo, absolutePath)
      ) {
        result.writerRelativeIncludes.set(
          absolutePath,
          IncludeRewriter.besideSource(absolutePath, this.headerExtension),
        );
      }
    }
  }
 
  /**
   * #1725: is this a quoted include whose spelling is relative to the file
   * that wrote it -- found beside that file, not along the search path?
   */
  private _resolvedBesideWriter(
    includeInfo: { path: string; isLocal: boolean },
    absolutePath: string,
  ): boolean {
    return (
      includeInfo.isLocal &&
      resolve(this.quotedIncludeDirectory, includeInfo.path) === absolutePath
    );
  }
 
  /**
   * Handle an unresolved include (warn for local includes only)
   */
  private _handleUnresolvedInclude(
    includeInfo: { path: string; isLocal: boolean },
    sourceFilePath: string | undefined,
    result: IResolvedIncludes,
  ): void {
    // An include that resolved to nothing still supplies names at compile
    // time -- unless it names C-Next source, which is not a C or C++ header
    // (ADR-030) whether it is found or not. #1435 made reaching a header
    // transitive, so counting a missing `.cnx` switched E0426 off for every
    // file that reached this one.
    if (FileDiscovery.classifyFile(includeInfo.path).type !== EFileType.CNext) {
      result.hasForeignInclude = true;
    }
 
    const warnings = result.warnings;
 
    // System includes (<...>) that aren't found are silently ignored
    if (!includeInfo.isLocal) return;
 
    const fromFile = sourceFilePath ? ` (from ${sourceFilePath})` : "";
    warnings.push(
      `#include "${includeInfo.path}" not found${fromFile}. ` +
        `Struct field types from this header will not be detected.`,
    );
  }
 
  /**
   * Reset the resolved paths set (for reuse across multiple files)
   */
  reset(): void {
    this.resolvedPaths.clear();
  }
 
  /**
   * Add already-resolved paths to prevent re-resolution
   */
  addResolvedPaths(paths: Iterable<string>): void {
    for (const path of paths) {
      this.resolvedPaths.add(path);
    }
  }
 
  /**
   * Check if a resolved include is a header file to process.
   */
  private static isProcessableHeader(file: IDiscoveredFile | null): boolean {
    return (
      file !== null &&
      (file.type === EFileType.CHeader || file.type === EFileType.CppHeader)
    );
  }
 
  /**
   * Read header content, returning null if not readable or if generated by C-Next.
   */
  private static readHeaderContent(
    file: IDiscoveredFile,
    fs: IFileSystem,
    warnings: string[],
    onDebug?: (message: string) => void,
  ): string | null {
    let content: string;
    try {
      content = fs.readFile(file.path);
    } catch {
      warnings.push(`Could not read header ${file.path}`);
      return null;
    }
 
    // #1435: the marker is the only thing that identifies a generated header
    // now, so it is asked of the one detector rather than spelled again here.
    if (CNextMarkerDetector.isCNextGenerated(content)) {
      onDebug?.(`Skipping C-Next generated header: ${file.path}`);
      return null;
    }
 
    return content;
  }
 
  /**
   * Issue #592: Recursively resolve all headers from a set of root headers.
   *
   * This method handles the recursive include graph traversal that was
   * previously in Transpiler.doCollectHeaderSymbols(). It:
   * - Discovers all nested #include directives
   * - Tracks visited paths to avoid cycles
   * - Returns headers in dependency order (dependencies first)
   * - Skips headers generated by C-Next Transpiler
   *
   * #1723: each root is searched along its own path, and every header it
   * reaches inherits that path -- the one discovery built for the `.cnx` file
   * that included the root, discovered tiers and all. A single list of
   * `--include` directories for every header lost a libdeps header's include
   * of a sibling library, which a compiler with PlatformIO's -I path finds.
   *
   * @param roots - The headers to resolve from, each with its search path
   * @param options - Optional configuration
   * @returns All headers (root + nested) in dependency order, and the search
   *   path each was resolved along -- the -I list its preprocessing must use
   */
  static resolveHeadersTransitively(
    roots: ReadonlyArray<IHeaderRoot>,
    options: {
      /** Callback for debug logging */
      onDebug?: (message: string) => void;
      /** File system abstraction: the port the host injected */
      fs: IFileSystem;
    },
  ): {
    headers: IDiscoveredFile[];
    searchPaths: ReadonlyMap<string, readonly string[]>;
    warnings: string[];
  } {
    const fs = options.fs;
    const visited = new Set<string>();
    const warnings: string[] = [];
    const depGraph = new DependencyGraph();
    const fileByPath = new Map<string, IDiscoveredFile>();
    const searchPathsByHeader = new Map<string, readonly string[]>();
 
    const processHeader = (
      file: IDiscoveredFile,
      rootSearchPaths: readonly string[],
    ): void => {
      const absolutePath = resolve(file.path);
 
      if (visited.has(absolutePath)) return;
      visited.add(absolutePath);
 
      const content = IncludeResolver.readHeaderContent(
        file,
        fs,
        warnings,
        options.onDebug,
      );
      if (!content) return;
 
      depGraph.addFile(absolutePath);
      fileByPath.set(absolutePath, file);
      searchPathsByHeader.set(file.path, rootSearchPaths);
 
      const includes = IncludeDiscovery.directivesOf(file.path, content);
      const searchPaths = [dirname(absolutePath), ...rootSearchPaths];
 
      options.onDebug?.(`Processing includes in ${file.path}:`);
      options.onDebug?.(`  Search paths: ${searchPaths.join(", ")}`);
 
      for (const includeInfo of includes) {
        const resolved = IncludeDiscovery.resolveInclude(
          includeInfo.path,
          searchPaths,
          fs,
        );
 
        options.onDebug?.(
          `  #include "${includeInfo.path}" → ${resolved ?? "NOT FOUND"}`,
        );
 
        if (!resolved) {
          if (includeInfo.isLocal) {
            warnings.push(
              `#include "${includeInfo.path}" not found (from ${file.path}). ` +
                `Struct field types from this header will not be detected.`,
            );
          }
          continue;
        }
 
        const includedFile = FileDiscovery.discoverFile(resolved, fs);
        Iif (!IncludeResolver.isProcessableHeader(includedFile)) continue;
 
        const includedPath = resolve(includedFile!.path);
        depGraph.addDependency(absolutePath, includedPath);
 
        options.onDebug?.(`    → Recursively processing ${includedFile!.path}`);
        processHeader(includedFile!, rootSearchPaths);
      }
    };
 
    for (const root of roots) {
      processHeader(root.file, root.searchPaths);
    }
 
    const sortedPaths = depGraph.getSortedFiles();
    warnings.push(...depGraph.getWarnings());
 
    const sortedHeaders: IDiscoveredFile[] = [];
    for (const path of sortedPaths) {
      const file = fileByPath.get(path);
      if (file) {
        sortedHeaders.push(file);
      }
    }
 
    return {
      headers: sortedHeaders,
      searchPaths: searchPathsByHeader,
      warnings,
    };
  }
 
  /**
   * Build search paths from a source file location
   *
   * Consolidates the search path building logic used by the unified
   * transpile() entry point.
   *
   * Search order (highest to lowest priority):
   * 1. Source file's directory (for relative includes)
   * 2. Additional include directories (e.g., from --include flag)
   * 3. Config include directories
   * 4. Project-level common directories (include/, src/, lib/)
   *
   * @param sourceDir - Directory containing the source file
   * @param includeDirs - Include directories from config
   * @param additionalIncludeDirs - Extra include directories (e.g., from API options)
   * @param projectRoot - Project root for common directory discovery, or undefined
   * @param fs - File system abstraction
   * @returns Array of search paths in priority order
   */
  static buildSearchPaths(
    sourceDir: string,
    includeDirs: string[],
    additionalIncludeDirs: string[],
    projectRoot: string | undefined,
    fs: IFileSystem,
  ): string[] {
    const paths: string[] = [];
 
    // Search path priority: 1) source dir, 2) additional dirs, 3) config dirs
    paths.push(sourceDir, ...additionalIncludeDirs, ...includeDirs);
 
    // 4. Project-level common directories
    const root = projectRoot ?? IncludeDiscovery.findProjectRoot(sourceDir, fs);
    if (root) {
      const commonDirs = ["include", "src", "lib"];
      for (const dir of commonDirs) {
        const includePath = join(root, dir);
        if (fs.exists(includePath) && fs.isDirectory(includePath)) {
          paths.push(includePath);
        }
      }
    }
 
    // Remove duplicates while preserving order
    return Array.from(new Set(paths));
  }
}
 
export default IncludeResolver;