scripts/index-code.ts
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`,/**
* 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`,