"""The static-analysis engine: ingested files -> a factual ProjectModel. This is StoryCode's source of truth. It dispatches each file to the right parser (Python `ast`, JS/TS tree-sitter, or the lightweight generic pass), reads the dependency manifests, then builds the project graph (edges, fan-in/out, roles, entry points, safe-to-edit). The language model is never involved here. """ from __future__ import annotations import posixpath import config from ingest import Ingested from schema import FileInfo, ProjectModel from analyzer import deps, generic, graph, js_treesitter, python_ast def _loc(text: str) -> int: return sum(1 for line in text.splitlines() if line.strip()) def _parse_one(path: str, lang: str, text: str): if lang == "python": return python_ast.parse(text) if lang in ("javascript", "typescript"): return js_treesitter.parse(text, lang) if lang in ("html", "css"): return generic.parse(text, lang) return [], [] def analyze_project(ingested: Ingested) -> ProjectModel: """Turn ingested source files into a fully-populated ProjectModel.""" files: list[FileInfo] = [] dependencies = [] langs: set[str] = set() for sf in ingested.files: base = posixpath.basename(sf.path).lower() if base in config.DEP_MANIFESTS: dependencies.extend(deps.parse_manifest(base, sf.text)) # package.json / pyproject also count as config files in the graph. symbols, imports = _parse_one(sf.path, sf.lang, sf.text) if sf.lang in config.LANG_BY_EXT.values(): langs.add(sf.lang) files.append(FileInfo( path=sf.path, lang=sf.lang, loc=_loc(sf.text), symbols=symbols, imports=imports, role=graph.classify_role(sf.path, sf.lang), )) graph.resolve_edges(files) graph.compute_metrics(files) # De-dup dependencies by (name, manifest), keep order. seen: set[tuple[str, str]] = set() uniq_deps = [] for d in dependencies: key = (d.name.lower(), d.manifest) if key not in seen: seen.add(key) uniq_deps.append(d) return ProjectModel( name=ingested.name, files=files, deps=uniq_deps, entry_points=graph.detect_entry_points(files), languages=sorted(langs), skipped=ingested.skipped, secrets_found=ingested.secrets_found, note=ingested.note, )