@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/README.md +280 -4
- package/bin/cli.mjs +313 -17
- package/package.json +2 -1
- package/src/ask.mjs +14 -0
- package/src/browser.mjs +16 -7
- package/src/chat.mjs +79 -14
- package/src/codegraph.mjs +7 -0
- package/src/config.mjs +7 -1
- package/src/extract.mjs +405 -48
- package/src/graph-format.mjs +156 -0
- package/src/graph-ops.mjs +92 -0
- package/src/manifest.mjs +148 -0
- package/src/server.mjs +11 -0
- package/src/sessions.mjs +7 -2
- package/src/source.mjs +34 -4
- package/src/store.mjs +282 -0
- package/src/summary.mjs +241 -0
- package/src/telemetry.mjs +90 -0
- package/src/timeline.mjs +12 -4
- package/src/toml-config.mjs +183 -0
- package/src/uuid.mjs +16 -0
- package/src/viz.mjs +72 -12
- package/src/walk.mjs +0 -0
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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) =>
|
|
77
|
-
|
|
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
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
|
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
|
-
|
|
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], …]}}]
|
|
136
|
-
* symbol-granular history pass. Parses the NEW-side hunk header (`+c,d`) into
|
|
137
|
-
* changed line range; extract.mjs intersects those with the (current) symbol
|
|
138
|
-
* This is the costly history pass, so it runs at the budgeted (capped) depth.
|
|
139
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
627
|
-
//
|
|
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
|
-
|
|
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,
|
|
637
|
-
runAst(repoPath, python),
|
|
638
|
-
ingestRepo(repoPath, { ignore }),
|
|
639
|
-
runGitLog(repoPath,
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|