scripts/index-code.ts

file·app·10.6 KB · 327 lignes· Voir l'itinéraire
Annotation non disponible

Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.

Code source· typescript· tronqué à 200 lignes sur 327

/**
 * Story 1.2 — Indexeur AST ts-morph
 *
 * Parcourt /wari/app + /wari/mobile et produit data/wari-graph.json
 * Format : { nodes: [{id, file, kind, exports[]}], edges: [{from, to, kind}] }
 *
 * Kind heuristics :
 *   - "model"     : prisma/schema.prisma (traité à part) ou path contenant /models/ avec interfaces
 *   - "route"     : app/api/.../route.ts (Next.js) ou app/.../route.ts
 *   - "hook"      : fichier dont default ou named export commence par "use" et est en camelCase
 *   - "component" : fichier .tsx avec default export en PascalCase
 *   - "function"  : tout fichier .ts exportant des fonctions (pas hook, pas component)
 *   - "file"      : fallback
 */

import { Project, SourceFile, SyntaxKind, ts } from "ts-morph";
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
import { join, relative, dirname } from "node:path";
import { performance } from "node:perf_hooks";
import { getProject, graphFileName, PROJECTS } from "../lib/projects";

// --- Configuration ---

// CLI : --project <id>    Défaut "wari" pour compat (npm run index sans flag).
const args = process.argv.slice(2);
const projectFlagIdx = args.indexOf("--project");
const PROJECT_ID = projectFlagIdx >= 0 ? args[projectFlagIdx + 1] : "wari";
if (!PROJECTS[PROJECT_ID]) {
  console.error(`[index-code] Projet inconnu : "${PROJECT_ID}". Choix : ${Object.keys(PROJECTS).join(", ")}`);
  process.exit(1);
}
const PROJECT = getProject(PROJECT_ID);

// Racines à parcourir : "app" toujours, "mobile" si défini dans la config projet.
const SOURCE_ROOTS: Array<{ path: string; prefix: "app" | "mobile" }> = [{ path: PROJECT.roots.app, prefix: "app" }];
if (PROJECT.roots.mobile) {
  SOURCE_ROOTS.push({ path: PROJECT.roots.mobile, prefix: "mobile" });
}

const OUT_DIR = join(process.cwd(), "data");
const OUT_FILE = join(OUT_DIR, graphFileName(PROJECT));

console.log(`[index-code] Projet : ${PROJECT.name} (${PROJECT_ID})`);
console.log(`[index-code] Racines : ${SOURCE_ROOTS.map((r) => `${r.prefix}=${r.path}`).join(" · ")}`);
console.log(`[index-code] Sortie : ${OUT_FILE}`);

const IGNORE_PATTERNS = [
  /\/node_modules\//,
  /\/\.next\//,
  /\/\.expo\//,
  /\/dist\//,
  /\/build\//,
  /\.test\.tsx?$/,
  /\.spec\.tsx?$/,
  /\.d\.ts$/,
  /\/__tests__\//,
  /\/__mocks__\//,
];

// --- Types output ---

type Kind = "model" | "route" | "hook" | "component" | "function" | "file";

interface GraphNode {
  id: string;
  file: string;
  root: "app" | "mobile";
  kind: Kind;
  exports: Array<{ name: string; type: "default" | "named" }>;
}

interface GraphEdge {
  from: string;
  to: string;
  kind: "import-relative" | "import-package";
  importedNames?: string[];
}

interface WariGraph {
  generatedAt: string;
  durationMs: number;
  stats: {
    totalNodes: number;
    totalEdges: number;
    byKind: Record<Kind, number>;
    byRoot: Record<"app" | "mobile", number>;
  };
  nodes: GraphNode[];
  edges: GraphEdge[];
}

// --- Helpers ---

function shouldIgnore(filePath: string): boolean {
  return IGNORE_PATTERNS.some((re) => re.test(filePath));
}

function detectKind(filePath: string, sourceFile: SourceFile): Kind {
  const lowerPath = filePath.toLowerCase();

  if (lowerPath.includes("/api/") && lowerPath.endsWith("/route.ts")) return "route";
  if (lowerPath.includes("/api/") && lowerPath.endsWith("/route.tsx")) return "route";
  if (lowerPath.match(/\/route\.tsx?$/) && lowerPath.includes("/app/")) return "route";

  if (lowerPath.includes("/models/") || lowerPath.endsWith("model.ts")) return "model";

  const exports = sourceFile.getExportedDeclarations();
  const exportNames = Array.from(exports.keys());

  const defaultExport = exports.get("default");
  if (defaultExport && defaultExport.length > 0) {
    const decl = defaultExport[0];
    const name = (decl as { getName?: () => string }).getName?.() ?? "default";
    if (name && /^use[A-Z]/.test(name)) return "hook";
    if (filePath.endsWith(".tsx") && name && /^[A-Z]/.test(name)) return "component";
  }

  if (exportNames.some((n) => /^use[A-Z]/.test(n))) return "hook";

  if (filePath.endsWith(".tsx") && exportNames.some((n) => /^[A-Z]/.test(n))) return "component";

  if (exportNames.length > 0) return "function";

  return "file";
}

function extractExports(sourceFile: SourceFile): Array<{ name: string; type: "default" | "named" }> {
  const result: Array<{ name: string; type: "default" | "named" }> = [];
  const exportedDeclarations = sourceFile.getExportedDeclarations();
  for (const [name] of exportedDeclarations) {
    if (name === "default") {
      result.push({ name: "default", type: "default" });
    } else {
      result.push({ name, type: "named" });
    }
  }
  return result;
}

function makeFileId(absolutePath: string, root: { path: string; prefix: string }): string {
  return `${root.prefix}/${relative(root.path, absolutePath)}`;
}

function resolveRelativeImport(
  fromFile: string,
  importPath: string,
  allFileIds: Set<string>,
): string | null {
  const fromDir = dirname(fromFile);
  // Normalise : remove ./ et résout ../
  const segments = (fromDir + "/" + importPath).split("/").filter(Boolean);
  const resolved: string[] = [];
  for (const seg of segments) {
    if (seg === "." || seg === "") continue;
    if (seg === "..") resolved.pop();
    else resolved.push(seg);
  }
  const base = resolved.join("/");
  // Try with extensions
  const candidates = [
    base + ".ts",
    base + ".tsx",
    base + "/index.ts",
    base + "/index.tsx",
  ];
  for (const c of candidates) {
    if (allFileIds.has(c)) return c;
  }
  return null;
}

// --- Main ---

async function indexCode() {
  const start = performance.now();
  console.log("🚀 dev-tour indexer — démarrage");

  // 1. Collect all files first to build allFileIds set
  console.log("📂 Scan des roots…");
  const project = new Project({
    skipAddingFilesFromTsConfig: true,
    skipFileDependencyResolution: true,
    skipLoadingLibFiles: true,
    compilerOptions: {
      allowJs: false,
      target: ts.ScriptTarget.ES2022,
      module: ts.ModuleKind.ESNext,
      jsx: ts.JsxEmit.React,
    },
  });

  const fileToRoot = new Map<string, { path: string; prefix: string }>();

  for (const root of SOURCE_ROOTS) {
    if (!existsSync(root.path)) {
      console.warn(`⚠️  Root introuvable : ${root.path}`);
      continue;
    }
    const added = project.addSourceFilesAtPaths([
      `${root.path}/**/*.ts`,