@polycode-projects/seonix 0.6.0 → 0.7.0

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.
package/src/extract.mjs CHANGED
@@ -18,15 +18,18 @@
18
18
  // mgx:subclassOf Class → Class (inheritance; internal base resolved, else ext:)
19
19
 
20
20
  import { spawn } from "node:child_process";
21
- import { writeFile, mkdir, readdir, stat } from "node:fs/promises";
21
+ import { writeFile, readFile, rename, rm, mkdir, readdir, stat } from "node:fs/promises";
22
22
  import { basename, dirname, join, parse, resolve, sep } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import * as codegraph from "./codegraph.mjs"; // optional renderToolsCatalog (other agent owns this file)
25
- import { ingestRepo, LANG_EXTS } from "./extract_lang.mjs";
25
+ import { ingestRepo, LANG_EXTS, REGISTRY } from "./extract_lang.mjs";
26
26
  import { loadIgnores } from "./walk.mjs";
27
27
  import { attachProseTokens, buildProseIndex } from "./prose.mjs";
28
28
  import { ingestSchemaDocs } from "./schema-docs.mjs";
29
29
  import { foldInSessions, readSessionRecords } from "./sessions.mjs";
30
+ import { encodeGraphV2 } from "./graph-format.mjs";
31
+ import { buildSummary, writeSummaryArtifacts } from "./summary.mjs";
32
+ import { fingerprintRepo, readManifest, writeManifest, diffManifest } from "./manifest.mjs";
30
33
 
31
34
  const here = dirname(fileURLToPath(import.meta.url));
32
35
  const AST_SCRIPT = join(here, "extract_ast.py");
@@ -64,17 +67,39 @@ function historySymbolDepth(env = process.env) {
64
67
  return Number.isFinite(n) && n >= 0 ? Math.floor(n) : HISTORY_SYMBOL_DEPTH;
65
68
  }
66
69
 
67
- /** spawn, collect stdout; resolve {code, stdout, stderr} (never reject). */
68
- function exec(cmd, args, { cwd, maxBuffer = 512 * 1024 * 1024 } = {}) {
70
+ // Git-history passes are the one place seonix shells an unbounded command over
71
+ // arbitrary repo history, so they get a hard wall-clock timeout (A5): a wedged or
72
+ // pathological `git log` can never hang an index. On fire we SIGKILL the child and
73
+ // resolve a synthetic failure ({code:-1, timedOut:true}) rather than rejecting.
74
+ const GIT_TIMEOUT_MS = 300_000;
75
+
76
+ /** spawn, collect stdout; resolve {code, stdout, stderr, timedOut, truncated}
77
+ * (never reject). `timeout` (ms, >0) arms a SIGKILL wall-clock; `truncated` is set
78
+ * when stdout exceeded maxBuffer (silently dropped bytes → an incomplete result the
79
+ * caller must NOT treat as authoritative). Exported for the A5 timeout unit test. */
80
+ export function exec(cmd, args, { cwd, maxBuffer = 512 * 1024 * 1024, timeout = 0 } = {}) {
69
81
  return new Promise((resolve) => {
70
82
  const child = spawn(cmd, args, { cwd });
71
83
  let stdout = "";
72
84
  let stderr = "";
73
85
  let size = 0;
74
- child.stdout?.on("data", (d) => { size += d.length; if (size <= maxBuffer) stdout += d; });
86
+ let truncated = false;
87
+ let timedOut = false;
88
+ const timer = timeout > 0 ? setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeout) : null;
89
+ child.stdout?.on("data", (d) => { size += d.length; if (size <= maxBuffer) stdout += d; else truncated = true; });
75
90
  child.stderr?.on("data", (d) => (stderr += d));
76
- child.on("close", (code) => resolve({ code: code ?? -1, stdout, stderr }));
77
- child.on("error", (err) => resolve({ code: -1, stdout, stderr: stderr + String(err) }));
91
+ child.on("close", (code) => {
92
+ if (timer) clearTimeout(timer);
93
+ if (timedOut) {
94
+ resolve({ code: -1, stdout, stderr: stderr + `timed out after ${Math.round(timeout / 1000)}s`, timedOut: true, truncated });
95
+ } else {
96
+ resolve({ code: code ?? -1, stdout, stderr, timedOut: false, truncated });
97
+ }
98
+ });
99
+ child.on("error", (err) => {
100
+ if (timer) clearTimeout(timer);
101
+ resolve({ code: -1, stdout, stderr: stderr + String(err), timedOut, truncated });
102
+ });
78
103
  });
79
104
  }
80
105
 
@@ -108,16 +133,30 @@ async function runAst(repoPath, python) {
108
133
  export const GIT_LOG_EXCLUDE = ["corpus", "target", "vendor", "infra/cdk.out", "results"];
109
134
  const gitPathspecExcludes = () => GIT_LOG_EXCLUDE.map((p) => `:(exclude)${p}`);
110
135
 
111
- /** git log [{sha, author, date, subject, files[]}] over the last N commits
112
- * (best-effort; [] if no repo). Header record fields are \x1e-separated; commits
113
- * are \x1f-separated; the subject (%s) is single-line so it never collides with
114
- * the per-file lines that follow. */
136
+ /** A one-line failure reason for a git-history exec, or null on clean success.
137
+ * Partial parseable output is ALWAYS kept by the caller this only records WHY the
138
+ * result may be incomplete (timeout / non-zero exit / maxBuffer truncation) so an
139
+ * index never silently loses history edges without saying so. */
140
+ function gitPassError({ code, stderr, timedOut, truncated }) {
141
+ if (timedOut) return "timed out after 300s";
142
+ if (code !== 0) {
143
+ const tail = String(stderr || "").trim().split("\n").pop()?.slice(-200) || "";
144
+ return `git exited ${code}${tail ? `: ${tail}` : ""}`;
145
+ }
146
+ if (truncated) return "output truncated — history incomplete";
147
+ return null;
148
+ }
149
+
150
+ /** git log → {commits:[{sha, author, date, subject, files[]}], error}. Header
151
+ * record fields are \x1e-separated; commits are \x1f-separated; the subject (%s) is
152
+ * single-line so it never collides with the per-file lines that follow. Parseable
153
+ * output is always returned even on failure; `error` is a one-line reason or null. */
115
154
  async function runGitLog(repoPath, depth = gitDepth()) {
116
- const { code, stdout } = await exec("git",
155
+ const res = await exec("git",
117
156
  ["log", `-n`, String(depth), "--no-renames", "--name-only",
118
157
  "--pretty=format:%x1f%H%x1e%an%x1e%aI%x1e%s", "--", ".", ...gitPathspecExcludes()],
119
- { cwd: repoPath });
120
- if (code !== 0) return [];
158
+ { cwd: repoPath, timeout: GIT_TIMEOUT_MS });
159
+ const { stdout } = res;
121
160
  const out = [];
122
161
  for (const chunk of stdout.split("\x1f")) {
123
162
  const nl = chunk.indexOf("\n");
@@ -129,21 +168,22 @@ async function runGitLog(repoPath, depth = gitDepth()) {
129
168
  .split("\n").map((l) => l.trim()).filter(isIndexedFile);
130
169
  out.push({ sha: sha.trim(), author, date, subject, files });
131
170
  }
132
- return out;
171
+ return { commits: out, error: gitPassError(res) };
133
172
  }
134
173
 
135
- /** git log -p --unified=0 → [{sha, ranges: {path: [[start,end], …]}}] for the
136
- * symbol-granular history pass. Parses the NEW-side hunk header (`+c,d`) into the
137
- * changed line range; extract.mjs intersects those with the (current) symbol spans.
138
- * This is the costly history pass, so it runs at the budgeted (capped) depth.
139
- * Best-effort: [] on any git failure or depth 0. */
174
+ /** git log -p --unified=0 → {hunks:[{sha, ranges: {path: [[start,end], …]}}], error}
175
+ * for the symbol-granular history pass. Parses the NEW-side hunk header (`+c,d`) into
176
+ * the changed line range; extract.mjs intersects those with the (current) symbol
177
+ * spans. This is the costly history pass, so it runs at the budgeted (capped) depth.
178
+ * Parseable output is always returned even on failure; `error` is a one-line reason
179
+ * or null. Depth 0 → no pass ({hunks:[], error:null}). */
140
180
  async function runGitLogHunks(repoPath, depth = historySymbolDepth()) {
141
- if (!depth) return [];
142
- const { code, stdout } = await exec("git",
181
+ if (!depth) return { hunks: [], error: null };
182
+ const res = await exec("git",
143
183
  ["log", `-n`, String(depth), "--no-renames", "--no-color", "--unified=0",
144
184
  "--pretty=format:%x1f%H", "--", ".", ...gitPathspecExcludes()],
145
- { cwd: repoPath });
146
- if (code !== 0) return [];
185
+ { cwd: repoPath, timeout: GIT_TIMEOUT_MS });
186
+ const { stdout } = res;
147
187
  const out = [];
148
188
  let cur = null;
149
189
  let file = null;
@@ -170,7 +210,7 @@ async function runGitLogHunks(repoPath, depth = historySymbolDepth()) {
170
210
  cur.ranges[file].push(count > 0 ? [start, start + count - 1] : [start, start]);
171
211
  }
172
212
  }
173
- return out;
213
+ return { hunks: out, error: gitPassError(res) };
174
214
  }
175
215
 
176
216
  /** Build the `entities` payload from the parsed modules + git history.
@@ -618,35 +658,119 @@ function localToolsCatalog(cliPath) {
618
658
 
619
659
  const DEFAULT_PYTHON = () => process.env.SEONIX_PYTHON || process.env.SEON_PYTHON || "python3";
620
660
 
661
+ /** Emit one immediate stderr WARNING per collected gitError so a git-history pass that
662
+ * failed (timeout / truncation / broken repo) is never invisible — the graph is still
663
+ * built, just without those edges. `who` is the repo prefix (multi) or path (single). */
664
+ function warnGitErrors(gitErrors, who) {
665
+ for (const { pass, message } of gitErrors || []) {
666
+ process.stderr.write(`seonix: WARNING ${who}: git history pass '${pass}' failed — ${message} (graph built without those edges)\n`);
667
+ }
668
+ }
669
+
670
+ /** OR-combine two ignore matchers (either may be null). Returns null when both are null, the
671
+ * sole non-null matcher when only one is set (reference-preserving → byte-identical default),
672
+ * or a predicate true when EITHER matches. */
673
+ function combineIgnores(a, b) {
674
+ if (a && b) return (rel) => a(rel) || b(rel);
675
+ return a || b || null;
676
+ }
677
+
678
+ /** Extension → language gate for the `languages` allowlist. A module survives when its
679
+ * extension belongs to an allowed language (python owns `.py`; the front-end languages own
680
+ * their REGISTRY exts). `null` allowlist → every module (today's behavior, byte-identical). */
681
+ function moduleLangAllowed(path, langAllowed) {
682
+ if (!langAllowed) return true;
683
+ const dot = path.lastIndexOf(".");
684
+ const ext = dot >= 0 ? path.slice(dot).toLowerCase() : "";
685
+ if (ext === ".py") return langAllowed.has("python");
686
+ for (const [lang, r] of Object.entries(REGISTRY)) {
687
+ if (r.exts.includes(ext)) return langAllowed.has(lang);
688
+ }
689
+ return true; // extension owned by no known language — never dropped by the gate
690
+ }
691
+
692
+ // seonix.toml [index].languages uses friendly names ("javascript", "typescript", "csharp");
693
+ // the extractor REGISTRY is keyed "js/ts" / "c#". Canonicalise the allowlist to the extractor
694
+ // keys — WITHOUT this, a languages list of friendly names silently drops every JS/TS/C# module
695
+ // (none would match a REGISTRY key), as this repo's own seonix.toml self-index did.
696
+ const LANG_CANON = {
697
+ javascript: "js/ts", typescript: "js/ts", js: "js/ts", ts: "js/ts", jsts: "js/ts", "js/ts": "js/ts",
698
+ csharp: "c#", cs: "c#", dotnet: "c#", "c#": "c#",
699
+ python: "python", py: "python", java: "java",
700
+ };
701
+ const canonicalLang = (l) => LANG_CANON[String(l).toLowerCase()] ?? String(l).toLowerCase();
702
+
621
703
  /** One repo's raw extraction — parsers + git, NO graph assembly. Both index modes
622
704
  * build on this; multi-repo runs it repo-by-repo so only one repo's raw source
623
- * (parser/git-log output) is in memory at a time. */
624
- async function extractRepo(repoPath, { python = DEFAULT_PYTHON(), ignores = true } = {}) {
705
+ * (parser/git-log output) is in memory at a time.
706
+ * `extraIgnores` (a matcher, e.g. compileGlobs of seonix.toml [index].exclude/secret_exclude)
707
+ * composes with the repo's .seonixignore; `languages` (allowlist) gates which extractors run
708
+ * and which modules survive. Both absent → today's behavior byte-identical. */
709
+ async function extractRepo(repoPath, { python = DEFAULT_PYTHON(), ignores = true, historyDepth, extraIgnores = null, languages } = {}) {
625
710
  const t0 = Date.now();
626
- // `.seonixignore` (opt-out: `ignores:false`, the benchmark rig's flag). The matcher
627
- // prunes the in-process walkers (extract_lang) at parse time; the module filter below
711
+ // `.seonixignore` (opt-out: `ignores:false`, the benchmark rig's flag), OR-composed with the
712
+ // seonix.toml [index] exclude/secret_exclude matcher forwarded as `extraIgnores`. The combined
713
+ // matcher prunes the in-process walkers (extract_lang) at parse time; the module filter below
628
714
  // is the AUTHORITATIVE cut — it also covers extractors that walk on their own
629
715
  // (extract_ast.py, the Java fat-jar, Roslyn), and buildEntities drops history edges
630
716
  // for any module not in the list, so the whole graph honours the same exclusions.
631
- const ignore = ignores ? await loadIgnores(repoPath) : null;
717
+ // Absent extraIgnores the bare .seonixignore matcher (same reference → byte-identical).
718
+ const baseIgnore = ignores ? await loadIgnores(repoPath) : null;
719
+ const ignore = combineIgnores(baseIgnore, extraIgnores);
720
+ // `languages` (seonix.toml [index].languages) — an allowlist gating which extractors run and
721
+ // which modules survive. Absent (or empty) → every language, byte-identical to today. Present →
722
+ // the Python `runAst` runs only when "python" is listed, the language front-end only when a
723
+ // language it owns is listed, and any surviving module whose extension belongs to a non-listed
724
+ // language is dropped from the final set (buildEntities then drops that module's history too).
725
+ const langAllowed = Array.isArray(languages) && languages.length ? new Set(languages.map(canonicalLang)) : null;
726
+ const runPython = !langAllowed || langAllowed.has("python");
727
+ const runLangs = !langAllowed || Object.keys(REGISTRY).some((k) => langAllowed.has(k));
728
+
729
+ // A5 `history_depth` — unified cap over BOTH git passes:
730
+ // undefined → today's defaults exactly (gitDepth()/historySymbolDepth()), byte-identical.
731
+ // 0 → skip BOTH passes entirely (no git shelled, no commits/touches/cochange).
732
+ // N>0 → both passes capped at N (`git log -n N`; the symbol pass uses N, not 120).
733
+ const skipHistory = historyDepth === 0;
734
+ const nameDepth = historyDepth === undefined ? gitDepth() : historyDepth;
735
+ const symbolDepth = historyDepth === undefined ? historySymbolDepth() : historyDepth;
736
+ // A git-less repo is a first-class supported case — no `.git` means no history, silently.
737
+ // A PRESENT-but-broken `.git` (e.g. a `gitdir:` pointer to nowhere) is a real failure we
738
+ // surface as a gitError, so we only skip-silently when there is genuinely no repo to read.
739
+ const hasGit = skipHistory ? false : await stat(join(repoPath, ".git")).then(() => true).catch(() => false);
740
+
741
+ const gitErrors = []; // [{pass, message}] — one-line reasons the history may be incomplete
632
742
  // base extraction: ast (Python) + multi-language front-end (TS/JS, C#) + module-level
633
743
  // git history (the cheap, full-depth pass). The Python and language passes are
634
744
  // independent parsers; their `{modules:[…]}` outputs share one contract and are merged
635
745
  // (dedupe by path — Python from runAst wins) before buildEntities.
636
- const [pyModules, langResult, commits] = await Promise.all([
637
- runAst(repoPath, python),
638
- ingestRepo(repoPath, { ignore }),
639
- runGitLog(repoPath, gitDepth()),
746
+ const [pyModules, langResult, gitLog] = await Promise.all([
747
+ runPython ? runAst(repoPath, python) : Promise.resolve([]),
748
+ runLangs ? ingestRepo(repoPath, { ignore }) : Promise.resolve({ modules: [], perLang: {} }),
749
+ hasGit ? runGitLog(repoPath, nameDepth) : Promise.resolve({ commits: [], error: null }),
640
750
  ]);
751
+ const commits = gitLog.commits;
752
+ if (gitLog.error) gitErrors.push({ pass: "name-only", message: gitLog.error });
641
753
  const seenPaths = new Set(pyModules.map((m) => m.path));
642
754
  const merged = [...pyModules, ...langResult.modules.filter((m) => !seenPaths.has(m.path))];
643
- const modules = ignore ? merged.filter((m) => !ignore(m.path)) : merged;
755
+ // Authoritative cut: drop ignored paths AND modules of a non-listed language (both no-ops when
756
+ // extraIgnores/languages are absent → byte-identical). perLang is filtered to listed languages.
757
+ const modules = merged.filter((m) => (!ignore || !ignore(m.path)) && moduleLangAllowed(m.path, langAllowed));
758
+ const perLang = langAllowed
759
+ ? Object.fromEntries(Object.entries(langResult.perLang).filter(([lang]) => langAllowed.has(lang)))
760
+ : langResult.perLang;
644
761
  const baseMs = Date.now() - t0;
645
762
  // added symbol-history pass (the costly per-commit hunk diff) — budgeted via depth
646
763
  const tHist = Date.now();
647
- const symbolHistory = await runGitLogHunks(repoPath, historySymbolDepth());
764
+ const runSymbol = hasGit && symbolDepth > 0;
765
+ const hunks = runSymbol ? await runGitLogHunks(repoPath, symbolDepth) : { hunks: [], error: null };
766
+ const symbolHistory = hunks.hunks;
767
+ if (hunks.error) gitErrors.push({ pass: "symbol-hunk", message: hunks.error });
648
768
  const historyMs = Date.now() - tHist;
649
- return { modules, perLang: langResult.perLang, commits, symbolHistory, baseMs, historyMs };
769
+ return {
770
+ modules, perLang, commits, symbolHistory, baseMs, historyMs, gitErrors,
771
+ // effective depths actually applied (0 when a pass was skipped: history_depth:0 or no .git)
772
+ historyDepth: { name: hasGit ? nameDepth : 0, symbol: runSymbol ? symbolDepth : 0 },
773
+ };
650
774
  }
651
775
 
652
776
  /** Assemble entities from (possibly merged) extractions and write the artifacts
@@ -665,7 +789,35 @@ async function assembleAndWrite(rootDir, { modules, commits, symbolHistory, gene
665
789
  if (sessions.length) foldInSessions(entities, sessions);
666
790
  const graphFile = join(rootDir, ".seonix", "graph.json");
667
791
  await mkdir(dirname(graphFile), { recursive: true });
668
- await writeFile(graphFile, JSON.stringify(entities));
792
+ // A6 wire format. The interned v2 form is the on-disk DEFAULT: it is fully wired on the READ
793
+ // side (source.mjs / parseEntities / sessions write-back / chronograph all expand it
794
+ // transparently) and proven byte-transparent by test/graph-format.test.mjs (digest/locate/
795
+ // describe parity) + the multi-repo golden. Any reader of the RAW graph.json must run it
796
+ // through `expandGraphPayload` first. Opt OUT to the legacy v1 form with SEONIX_GRAPH_FORMAT=1.
797
+ // `encodeGraphV2` does NOT mutate `entities`, so the C12 writeStore below still consumes the
798
+ // format-agnostic in-memory object (stamped with the SAME on-disk bytes via `sourceText`).
799
+ const wireV2 = process.env.SEONIX_GRAPH_FORMAT !== "1";
800
+ const payload = JSON.stringify(wireV2 ? encodeGraphV2(entities) : entities);
801
+ await writeFile(graphFile, payload);
802
+ const graphBytes = payload.length; // chars ≈ bytes; feeds the A1 summary's size + cap headroom
803
+
804
+ // C12 (opt-in): SEONIX_STORE=sqlite → also build the resident node:sqlite store from
805
+ // the same in-memory payload, stamped with the JSON's stat for freshness. Mirrors the
806
+ // SEONIX_PROSE_INDEX env-gate precedent; unset → no import of node:sqlite, no graph.db,
807
+ // byte-identical default path. Best-effort: graph.json stays authoritative on any failure.
808
+ if (process.env.SEONIX_STORE === "sqlite") {
809
+ try {
810
+ const { writeStore, storeFileFor } = await import("./store.mjs");
811
+ const sourceStat = await stat(graphFile);
812
+ // `sourceText: payload` = the exact bytes just written to graph.json (v2 by default, or v1).
813
+ // Without it writeStore would hash JSON.stringify(entities) — the v1 form — against a v2 disk
814
+ // file, so verifyStore/readPayload-freshness would fail on every fresh v2 store. Mirrors
815
+ // bin/cli.mjs store_rebuild (which passes sourceText: text).
816
+ await writeStore(storeFileFor(graphFile), entities, { sourceStat, sourceText: payload });
817
+ } catch (e) {
818
+ process.stderr.write(`seonix: sqlite store build failed (${e?.message || e}) — graph.json is authoritative\n`);
819
+ }
820
+ }
669
821
 
670
822
  // TOOLS.md catalog (prefer the other agent's renderToolsCatalog when it lands).
671
823
  // TODO: switch to codegraph.renderToolsCatalog(CLI_SCRIPT) once that export exists.
@@ -673,7 +825,7 @@ async function assembleAndWrite(rootDir, { modules, commits, symbolHistory, gene
673
825
  ? codegraph.renderToolsCatalog(CLI_SCRIPT)
674
826
  : localToolsCatalog(CLI_SCRIPT);
675
827
  await writeFile(join(rootDir, ".seonix", "TOOLS.md"), toolsMd);
676
- return { entities, graphFile };
828
+ return { entities, graphFile, graphBytes };
677
829
  }
678
830
 
679
831
  function buildCounts(entities, { modules, languages, commits, proseEnabled, baseMs, historyMs }) {
@@ -703,15 +855,36 @@ function buildCounts(entities, { modules, languages, commits, proseEnabled, base
703
855
  * symbol-history (line-range) pass so the latter can be kept within the ≤10% budget.
704
856
  * @returns {Promise<{graphFile, counts}>}
705
857
  */
706
- export async function indexRepository(repoPath, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true } = {}) {
858
+ export async function indexRepository(repoPath, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, historyDepth, extraIgnores = null, languages } = {}) {
707
859
  // Second pass (PLAN_PROSE_INDEX.md): on by default; SEONIX_PROSE_INDEX=0 disables it
708
860
  // (mirrors SEONIX_HISTORY_SYMBOL_DEPTH=0's disable convention above).
709
861
  const proseEnabled = process.env.SEONIX_PROSE_INDEX !== "0";
710
- const { modules, perLang, commits, symbolHistory, baseMs, historyMs } = await extractRepo(repoPath, { python, ignores });
862
+ const r = await extractRepo(repoPath, { python, ignores, historyDepth, extraIgnores, languages });
863
+ const { modules, perLang, commits, symbolHistory, baseMs, historyMs, gitErrors } = r;
864
+ warnGitErrors(gitErrors, repoPath); // surface any history-pass failure immediately (never fatal)
711
865
  const sessions = await readSessionRecords(repoPath); // recorded chat sessions (.seonix/sessions/*.jsonl), [] when none
712
- const { entities, graphFile } = await assembleAndWrite(repoPath, { modules, commits, symbolHistory, generatedAt, proseEnabled, sessions });
866
+ const { entities, graphFile, graphBytes } = await assembleAndWrite(repoPath, { modules, commits, symbolHistory, generatedAt, proseEnabled, sessions });
867
+ // A1 post-index summary: SUMMARY.md + summary.json next to graph.json (NEW files; the
868
+ // graph.json bytes are untouched). The rendered MD is also printed to stdout by cli.mjs.
869
+ const summary = buildSummary(entities, {
870
+ mode: "single",
871
+ repos: [{
872
+ name: basename(resolve(repoPath)),
873
+ modules: modules.length,
874
+ commits: commits.length,
875
+ historyDepth: r.historyDepth,
876
+ gitErrors,
877
+ }],
878
+ skipped: [],
879
+ languages: perLang,
880
+ graphBytes,
881
+ });
882
+ await writeSummaryArtifacts(dirname(graphFile), summary);
713
883
  return {
714
884
  graphFile,
885
+ gitErrors, // [{pass, message}] — [] on a clean build (A5; for the later summary/manifest)
886
+ historyDepth: r.historyDepth, // effective per-pass depths actually applied
887
+ summary, // A1: the buildSummary object (cli renders it to stdout)
715
888
  counts: buildCounts(entities, { modules: modules.length, languages: perLang, commits: commits.length, proseEnabled, baseMs, historyMs }),
716
889
  };
717
890
  }
@@ -775,7 +948,8 @@ export function defaultOutRoot(repoPaths, cwd = process.cwd()) {
775
948
  /** Discovery for the estate case: every immediate child directory of `multiRoot`
776
949
  * that carries a `.git` (dir OR file — worktrees/submodules have .git files) is a
777
950
  * repo. Dot-dirs are ignored outright; plain child dirs without .git are returned
778
- * as `skipped` so the caller can log them. */
951
+ * as `skipped` ([{name, reason}]) so the caller can log them and the A1 summary can
952
+ * record why each was left out. */
779
953
  export async function discoverRepos(multiRoot) {
780
954
  const root = resolve(multiRoot);
781
955
  const entries = await readdir(root, { withFileTypes: true });
@@ -788,7 +962,7 @@ export async function discoverRepos(multiRoot) {
788
962
  await stat(join(root, e.name, ".git"));
789
963
  repos.push(join(root, e.name));
790
964
  } catch {
791
- skipped.push(e.name);
965
+ skipped.push({ name: e.name, reason: "no .git marker" });
792
966
  }
793
967
  }
794
968
  return { repos, skipped };
@@ -802,7 +976,7 @@ export async function discoverRepos(multiRoot) {
802
976
  * `log` (optional) receives one progress line per repo.
803
977
  * @returns {Promise<{graphFile, outRoot, repos, counts}>}
804
978
  */
805
- export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, outRoot = "", log = () => {} } = {}) {
979
+ export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, outRoot = "", log = () => {}, historyDepth, skipped = [], extraIgnores = null, languages: languageAllow } = {}) {
806
980
  const paths = [...new Set((repoPaths || []).map((p) => resolve(p)))];
807
981
  if (!paths.length) throw new Error("indexRepositories requires at least one repo path");
808
982
  const proseEnabled = process.env.SEONIX_PROSE_INDEX !== "0";
@@ -818,7 +992,8 @@ export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(),
818
992
  let historyMs = 0;
819
993
  for (const rp of paths) {
820
994
  const prefix = prefixes.get(rp);
821
- const r = await extractRepo(rp, { python, ignores });
995
+ const r = await extractRepo(rp, { python, ignores, historyDepth, extraIgnores, languages: languageAllow });
996
+ warnGitErrors(r.gitErrors, prefix); // surface any history-pass failure immediately (never fatal)
822
997
  applyRepoPrefix(r, prefix);
823
998
  allModules.push(...r.modules);
824
999
  allCommits.push(...r.commits);
@@ -829,20 +1004,202 @@ export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(),
829
1004
  const agg = languages[lang] || (languages[lang] = { lib: s.lib, files: 0, modules: 0, symbols: 0, failures: 0, ms: 0 });
830
1005
  agg.files += s.files; agg.modules += s.modules; agg.symbols += s.symbols; agg.failures += s.failures; agg.ms += s.ms;
831
1006
  }
832
- repos.push({ path: rp, prefix, modules: r.modules.length, commits: r.commits.length });
1007
+ // per-repo return rows carry the effective history depth + any git errors (A5;
1008
+ // consumed by the later summary/manifest items — buildEntities stays untouched here).
1009
+ repos.push({ path: rp, prefix, modules: r.modules.length, commits: r.commits.length, gitErrors: r.gitErrors, historyDepth: r.historyDepth });
833
1010
  log(`indexed ${prefix} (${rp}): ${r.modules.length} modules, ${r.commits.length} commits in ${r.baseMs + r.historyMs}ms`);
834
1011
  }
835
1012
 
836
1013
  // TODO(sessions): multi-repo merges don't fold in the member repos' .seonix/sessions
837
1014
  // yet — their recorded ids would need the same repo-name prefixing as modules to
838
1015
  // re-resolve against the merged graph. Deferred; single-path fold-in is the contract.
839
- const { entities, graphFile } = await assembleAndWrite(root, {
1016
+ const { entities, graphFile, graphBytes } = await assembleAndWrite(root, {
840
1017
  modules: allModules, commits: allCommits, symbolHistory: allSymbolHistory, generatedAt, proseEnabled,
841
1018
  });
1019
+ // A1 post-index summary (merged): per-repo rows (name = prefix) + any discovery
1020
+ // `skipped` rows threaded through by the cli. NEW files next to graph.json.
1021
+ const summary = buildSummary(entities, {
1022
+ mode: "multi",
1023
+ repos: repos.map((rr) => ({
1024
+ name: rr.prefix,
1025
+ modules: rr.modules,
1026
+ commits: rr.commits,
1027
+ historyDepth: rr.historyDepth,
1028
+ gitErrors: rr.gitErrors,
1029
+ })),
1030
+ skipped,
1031
+ languages,
1032
+ graphBytes,
1033
+ });
1034
+ await writeSummaryArtifacts(dirname(graphFile), summary);
842
1035
  return {
843
1036
  graphFile,
844
1037
  outRoot: root,
845
1038
  repos,
1039
+ summary, // A1: the buildSummary object (cli renders it to stdout)
1040
+ counts: buildCounts(entities, { modules: allModules.length, languages, commits: allCommits.length, proseEnabled, baseMs, historyMs }),
1041
+ };
1042
+ }
1043
+
1044
+ // ── A7: estate manifest + incremental sync ───────────────────────────────────
1045
+ // syncRepositories re-indexes a multi_root estate incrementally: fingerprint every
1046
+ // child repo (manifest.mjs), diff against the on-disk manifest, and RE-EXTRACT only the
1047
+ // added/changed repos (plus any unchanged repo whose extraction cache is missing —
1048
+ // self-heal). Every other repo's (post-prefix) extraction is reloaded from a per-repo
1049
+ // cache at `.seonix/cache/<name>.json`. The union of all repos' extractions then flows
1050
+ // through ONE buildEntities pass, exactly as a from-scratch multi index would.
1051
+ //
1052
+ // SEMANTIC CAVEAT: buildEntities resolves calls/inherits/bases through a GLOBAL
1053
+ // unique-name registry that spans EVERY repo, so per-repo sub-graphs can NOT simply be
1054
+ // concatenated (cross-repo name collisions change resolution). That is why the default
1055
+ // sync path re-runs the single union buildEntities over the cached+fresh extractions —
1056
+ // it is bit-for-bit the full-re-index result. graph-ops.dropByRepoPrefix is the
1057
+ // removals-only fast path (approximate for additions; see its header), not used here.
1058
+ //
1059
+ // The per-repo cache is written ONLY on sync runs; a normal index writes no cache, so
1060
+ // its artifacts stay byte-identical.
1061
+
1062
+ const CACHE_SUBDIR = "cache";
1063
+ const cachePath = (artifactDir, name) => join(artifactDir, CACHE_SUBDIR, `${name}.json`);
1064
+
1065
+ async function readCache(artifactDir, name) {
1066
+ return JSON.parse(await readFile(cachePath(artifactDir, name), "utf8"));
1067
+ }
1068
+ async function writeCache(artifactDir, name, payload) {
1069
+ await mkdir(join(artifactDir, CACHE_SUBDIR), { recursive: true });
1070
+ const p = cachePath(artifactDir, name);
1071
+ const tmp = `${p}.tmp-${process.pid}`;
1072
+ await writeFile(tmp, JSON.stringify(payload));
1073
+ await rename(tmp, p);
1074
+ }
1075
+ const cacheExists = (artifactDir, name) => stat(cachePath(artifactDir, name)).then(() => true).catch(() => false);
1076
+
1077
+ /** Every immediate, non-dot child DIRECTORY of `root` (absolute paths, sorted). Broader
1078
+ * than discoverRepos (which requires a .git marker) because sync fingerprints plain-dir
1079
+ * and git-subdir members too — classification is manifest.fingerprintRepo's job. */
1080
+ async function discoverSyncRepos(root) {
1081
+ const entries = await readdir(root, { withFileTypes: true });
1082
+ return entries
1083
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
1084
+ .map((e) => e.name)
1085
+ .sort()
1086
+ .map((name) => join(root, name));
1087
+ }
1088
+
1089
+ /**
1090
+ * Incrementally (re-)index a multi_root estate. Fingerprint → diff manifest → re-extract
1091
+ * only the changed members → ONE buildEntities over the union (cached + fresh) →
1092
+ * assembleAndWrite + summary + manifest, all via temp+rename. When NOTHING changed
1093
+ * (and every cache is present) it is a byte-stable no-op — no artifact is rewritten.
1094
+ * `log` receives one line per re-extracted repo (and the "N unchanged" no-op line).
1095
+ * @returns {Promise<{graphFile, outRoot, noop, added, changed, removed, reextracted, unchanged, summary?, counts?}>}
1096
+ */
1097
+ export async function syncRepositories(multiRoot, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, historyDepth, log = () => {} } = {}) {
1098
+ const root = resolve(multiRoot);
1099
+ const artifactDir = join(root, ".seonix");
1100
+ const proseEnabled = process.env.SEONIX_PROSE_INDEX !== "0";
1101
+
1102
+ const paths = await discoverSyncRepos(root);
1103
+ if (!paths.length) throw new Error(`sync: ${root} has no child repositories`);
1104
+ const prefixes = assignRepoPrefixes(paths);
1105
+
1106
+ // Fingerprint every member → the "current" rows (name = the graph-id prefix so the
1107
+ // manifest, the cache keys and dropByRepoPrefix all agree on one repo identity).
1108
+ const current = [];
1109
+ for (const rp of paths) {
1110
+ const name = prefixes.get(rp);
1111
+ const fp = await fingerprintRepo(rp, { multiRoot: root });
1112
+ current.push({ name, path: rp, kind: fp.kind, fingerprint: fp.fingerprint });
1113
+ }
1114
+
1115
+ const prev = await readManifest(artifactDir);
1116
+ const diff = diffManifest(current, prev.repos || []);
1117
+
1118
+ // Members to re-extract: added + changed, plus any UNCHANGED member whose per-repo
1119
+ // cache file is missing (self-heal — the cache is the reuse contract).
1120
+ const reason = new Map();
1121
+ for (const r of diff.added) reason.set(r.name, "added");
1122
+ for (const r of diff.changed) reason.set(r.name, "changed");
1123
+ for (const r of diff.unchanged) {
1124
+ if (!(await cacheExists(artifactDir, r.name))) reason.set(r.name, "cache-miss");
1125
+ }
1126
+
1127
+ // Byte-stable no-op: nothing added/changed/removed and every cache present → do NOT
1128
+ // rewrite any artifact (graph.json keeps its exact bytes + mtime).
1129
+ if (reason.size === 0 && !diff.removed.length) {
1130
+ log(`${current.length} repo(s) unchanged`);
1131
+ return {
1132
+ graphFile: join(artifactDir, "graph.json"), outRoot: root, noop: true,
1133
+ added: [], changed: [], removed: [], reextracted: [],
1134
+ unchanged: current.map((r) => r.name),
1135
+ };
1136
+ }
1137
+
1138
+ const allModules = [];
1139
+ const allCommits = [];
1140
+ const allSymbolHistory = [];
1141
+ const languages = {};
1142
+ const repoRows = []; // → buildSummary repos
1143
+ const manifestRows = []; // → the rewritten manifest
1144
+ const reextracted = [];
1145
+ let baseMs = 0;
1146
+ let historyMs = 0;
1147
+
1148
+ for (const rp of paths) {
1149
+ const name = prefixes.get(rp);
1150
+ const row = current.find((r) => r.name === name);
1151
+ let ext;
1152
+ if (reason.has(name)) {
1153
+ log(`re-extracting ${name} (${reason.get(name)})`);
1154
+ const r = await extractRepo(rp, { python, ignores, historyDepth });
1155
+ warnGitErrors(r.gitErrors, name);
1156
+ applyRepoPrefix(r, name); // prefix in place → the cache stores the POST-prefix extraction
1157
+ ext = {
1158
+ modules: r.modules, commits: r.commits, symbolHistory: r.symbolHistory,
1159
+ perLang: r.perLang, historyDepth: r.historyDepth, gitErrors: r.gitErrors,
1160
+ fingerprint: row.fingerprint,
1161
+ };
1162
+ await writeCache(artifactDir, name, ext);
1163
+ baseMs += r.baseMs; historyMs += r.historyMs;
1164
+ reextracted.push(name);
1165
+ } else {
1166
+ ext = await readCache(artifactDir, name); // reused post-prefix extraction
1167
+ }
1168
+ allModules.push(...ext.modules);
1169
+ allCommits.push(...ext.commits);
1170
+ allSymbolHistory.push(...ext.symbolHistory);
1171
+ for (const [lang, s] of Object.entries(ext.perLang || {})) {
1172
+ const agg = languages[lang] || (languages[lang] = { lib: s.lib, files: 0, modules: 0, symbols: 0, failures: 0, ms: 0 });
1173
+ agg.files += s.files; agg.modules += s.modules; agg.symbols += s.symbols; agg.failures += s.failures; agg.ms += s.ms;
1174
+ }
1175
+ repoRows.push({ name, modules: ext.modules.length, commits: ext.commits.length, historyDepth: ext.historyDepth, gitErrors: ext.gitErrors || [] });
1176
+ const head = row.fingerprint.head ?? row.fingerprint.walk ?? "";
1177
+ manifestRows.push({
1178
+ name, path: rp, kind: row.kind, fingerprint: row.fingerprint,
1179
+ history_depth: ext.historyDepth, indexed_at_head: head,
1180
+ modules: ext.modules.length, commits: ext.commits.length,
1181
+ });
1182
+ }
1183
+
1184
+ // Drop stale caches for removed members (their rows/ids fall out of the union rebuild).
1185
+ for (const r of diff.removed) await rm(cachePath(artifactDir, r.name), { force: true }).catch(() => {});
1186
+
1187
+ // ONE buildEntities over the union (cached + fresh) → the exact full-re-index graph.
1188
+ const { entities, graphFile, graphBytes } = await assembleAndWrite(root, {
1189
+ modules: allModules, commits: allCommits, symbolHistory: allSymbolHistory, generatedAt, proseEnabled,
1190
+ });
1191
+ const summary = buildSummary(entities, { mode: "multi", repos: repoRows, skipped: [], languages, graphBytes });
1192
+ await writeSummaryArtifacts(dirname(graphFile), summary);
1193
+ await writeManifest(artifactDir, { format: 1, generated_at: generatedAt, repos: manifestRows });
1194
+
1195
+ return {
1196
+ graphFile, outRoot: root, noop: false,
1197
+ added: diff.added.map((r) => r.name),
1198
+ changed: diff.changed.map((r) => r.name),
1199
+ removed: diff.removed.map((r) => r.name),
1200
+ reextracted,
1201
+ unchanged: diff.unchanged.map((r) => r.name).filter((n) => !reextracted.includes(n)),
1202
+ summary,
846
1203
  counts: buildCounts(entities, { modules: allModules.length, languages, commits: allCommits.length, proseEnabled, baseMs, historyMs }),
847
1204
  };
848
1205
  }