@harness-engineering/graph 0.7.1 → 0.9.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/dist/index.mjs CHANGED
@@ -91,7 +91,7 @@ var EDGE_TYPES = [
91
91
  "caches"
92
92
  ];
93
93
  var OBSERVABILITY_TYPES = /* @__PURE__ */ new Set(["span", "metric", "log"]);
94
- var CURRENT_SCHEMA_VERSION = 1;
94
+ var CURRENT_SCHEMA_VERSION = 2;
95
95
  var NODE_STABILITY = {
96
96
  File: "session",
97
97
  Function: "session",
@@ -129,39 +129,100 @@ var GraphEdgeSchema = z.object({
129
129
 
130
130
  // src/store/Serializer.ts
131
131
  import { readFile, writeFile, mkdir, access } from "fs/promises";
132
- import { createWriteStream } from "fs";
132
+ import { createReadStream, createWriteStream } from "fs";
133
+ import { createInterface } from "readline";
133
134
  import { join } from "path";
134
- function streamGraphJson(filePath, nodes, edges) {
135
+ function streamGraphNdjson(filePath, nodes, edges) {
135
136
  return new Promise((resolve, reject) => {
136
137
  const stream = createWriteStream(filePath, { encoding: "utf-8" });
137
138
  stream.on("error", reject);
138
- stream.write('{"nodes":[');
139
- for (let i = 0; i < nodes.length; i++) {
140
- if (i > 0) stream.write(",");
141
- stream.write(JSON.stringify(nodes[i]));
139
+ for (const node of nodes) {
140
+ stream.write(JSON.stringify({ kind: "node", ...node }));
141
+ stream.write("\n");
142
142
  }
143
- stream.write('],"edges":[');
144
- for (let i = 0; i < edges.length; i++) {
145
- if (i > 0) stream.write(",");
146
- stream.write(JSON.stringify(edges[i]));
143
+ for (const edge of edges) {
144
+ stream.write(JSON.stringify({ kind: "edge", ...edge }));
145
+ stream.write("\n");
147
146
  }
148
- stream.write("]}");
149
147
  stream.end(() => resolve());
150
148
  });
151
149
  }
150
+ function countNodesByType(nodes) {
151
+ const counts = {};
152
+ for (const node of nodes) {
153
+ counts[node.type] = (counts[node.type] ?? 0) + 1;
154
+ }
155
+ return counts;
156
+ }
152
157
  async function saveGraph(dirPath, nodes, edges) {
153
158
  await mkdir(dirPath, { recursive: true });
154
159
  const metadata = {
155
160
  schemaVersion: CURRENT_SCHEMA_VERSION,
156
161
  lastScanTimestamp: (/* @__PURE__ */ new Date()).toISOString(),
157
162
  nodeCount: nodes.length,
158
- edgeCount: edges.length
163
+ edgeCount: edges.length,
164
+ nodesByType: countNodesByType(nodes)
159
165
  };
160
166
  await Promise.all([
161
- streamGraphJson(join(dirPath, "graph.json"), nodes, edges),
167
+ streamGraphNdjson(join(dirPath, "graph.json"), nodes, edges),
162
168
  writeFile(join(dirPath, "metadata.json"), JSON.stringify(metadata, null, 2), "utf-8")
163
169
  ]);
164
170
  }
171
+ async function loadGraphMetadata(dirPath) {
172
+ const metaPath = join(dirPath, "metadata.json");
173
+ try {
174
+ await access(metaPath);
175
+ } catch {
176
+ return { status: "not_found" };
177
+ }
178
+ let metadata;
179
+ try {
180
+ const content = await readFile(metaPath, "utf-8");
181
+ metadata = JSON.parse(content);
182
+ } catch {
183
+ return { status: "not_found" };
184
+ }
185
+ if (metadata.schemaVersion !== CURRENT_SCHEMA_VERSION) {
186
+ return {
187
+ status: "schema_mismatch",
188
+ found: metadata.schemaVersion,
189
+ expected: CURRENT_SCHEMA_VERSION
190
+ };
191
+ }
192
+ return { status: "loaded", metadata };
193
+ }
194
+ async function streamReadGraph(filePath) {
195
+ const stream = createReadStream(filePath, { encoding: "utf-8" });
196
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
197
+ const nodes = [];
198
+ const edges = [];
199
+ try {
200
+ for await (const rawLine of rl) {
201
+ const line = rawLine.trim();
202
+ if (line === "") continue;
203
+ const record = JSON.parse(line);
204
+ if (record.kind === "node") {
205
+ const { kind: _kind, ...rest } = record;
206
+ nodes.push(rest);
207
+ } else if (record.kind === "edge") {
208
+ const { kind: _kind, ...rest } = record;
209
+ edges.push(rest);
210
+ }
211
+ }
212
+ } catch (err) {
213
+ if (err instanceof RangeError && /invalid string length/i.test(err.message)) {
214
+ throw new Error(
215
+ `Graph contains a record larger than V8 can hold in a single string (~512 MB). This usually means a node or edge has a multi-hundred-MB content/embedding field. Inspect ${filePath} for the offending record.`,
216
+ { cause: err }
217
+ );
218
+ }
219
+ throw err;
220
+ } finally {
221
+ rl.close();
222
+ stream.close();
223
+ }
224
+ return { nodes, edges };
225
+ }
165
226
  async function loadGraph(dirPath) {
166
227
  const metaPath = join(dirPath, "metadata.json");
167
228
  const graphPath = join(dirPath, "graph.json");
@@ -180,8 +241,8 @@ async function loadGraph(dirPath) {
180
241
  expected: CURRENT_SCHEMA_VERSION
181
242
  };
182
243
  }
183
- const graphContent = await readFile(graphPath, "utf-8");
184
- return { status: "loaded", graph: JSON.parse(graphContent) };
244
+ const graph = await streamReadGraph(graphPath);
245
+ return { status: "loaded", graph };
185
246
  }
186
247
 
187
248
  // src/store/GraphStore.ts
@@ -727,6 +788,87 @@ function groupNodesByImpact(nodes, excludeId) {
727
788
  // src/ingest/CodeIngestor.ts
728
789
  import * as fs from "fs/promises";
729
790
  import * as path from "path";
791
+ import { minimatch } from "minimatch";
792
+
793
+ // src/ingest/skip-dirs.ts
794
+ var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
795
+ // VCS
796
+ ".git",
797
+ ".hg",
798
+ ".svn",
799
+ // Package managers
800
+ "node_modules",
801
+ ".pnpm-store",
802
+ ".yarn",
803
+ "vendor",
804
+ // JS/TS build outputs
805
+ "dist",
806
+ "build",
807
+ "out",
808
+ "_build",
809
+ "bin",
810
+ "obj",
811
+ "target",
812
+ "deps",
813
+ // JS/TS framework / tooling caches
814
+ ".next",
815
+ ".nuxt",
816
+ ".svelte-kit",
817
+ ".turbo",
818
+ ".vite",
819
+ ".cache",
820
+ ".parcel-cache",
821
+ ".docusaurus",
822
+ ".wrangler",
823
+ ".astro",
824
+ ".remix",
825
+ "storybook-static",
826
+ // Test / coverage / reporter outputs
827
+ "coverage",
828
+ ".nyc_output",
829
+ ".pytest_cache",
830
+ "playwright-report",
831
+ "test-results",
832
+ ".e2e",
833
+ // Python
834
+ "__pycache__",
835
+ ".venv",
836
+ "venv",
837
+ ".tox",
838
+ ".mypy_cache",
839
+ ".ruff_cache",
840
+ // JVM
841
+ ".gradle",
842
+ ".gradle-home",
843
+ // IDE / editor
844
+ ".idea",
845
+ ".vscode",
846
+ ".vs",
847
+ // Harness self
848
+ ".harness",
849
+ // AI agent sandboxes (these often contain full repo clones — skipping them
850
+ // prevents the walker from re-ingesting nested copies of the project)
851
+ ".claude",
852
+ ".cursor",
853
+ ".codex",
854
+ ".gemini",
855
+ ".aider",
856
+ ".agents",
857
+ ".agentastic",
858
+ ".playwright-mcp"
859
+ ]);
860
+ function resolveSkipDirs(options) {
861
+ const base = options?.skipDirs ? new Set(options.skipDirs) : new Set(DEFAULT_SKIP_DIRS);
862
+ if (options?.additionalSkipDirs) {
863
+ for (const name of options.additionalSkipDirs) base.add(name);
864
+ }
865
+ return base;
866
+ }
867
+ function skipDirGlobs(skipDirs = DEFAULT_SKIP_DIRS) {
868
+ return Array.from(skipDirs, (name) => `**/${name}/**`);
869
+ }
870
+
871
+ // src/ingest/CodeIngestor.ts
730
872
  var SKIP_METHOD_NAMES = /* @__PURE__ */ new Set(["constructor", "if", "for", "while", "switch"]);
731
873
  var FUNCTION_PATTERNS = {
732
874
  python: /^def\s+(\w+)\s*\(/,
@@ -796,11 +938,55 @@ function isSupportedSourceFile(name) {
796
938
  const ext = name.slice(name.lastIndexOf("."));
797
939
  return SUPPORTED_EXTENSIONS.has(ext);
798
940
  }
941
+ function isExcluded(relPath, patterns) {
942
+ if (patterns.length === 0) return false;
943
+ for (const pattern of patterns) {
944
+ if (minimatch(relPath, pattern, { dot: true, matchBase: false })) return true;
945
+ if (!pattern.includes("/") && minimatch(relPath, pattern, { dot: true, matchBase: true })) {
946
+ return true;
947
+ }
948
+ }
949
+ return false;
950
+ }
951
+ async function readGitignorePatterns(rootDir) {
952
+ let content;
953
+ try {
954
+ content = await fs.readFile(path.join(rootDir, ".gitignore"), "utf-8");
955
+ } catch {
956
+ return [];
957
+ }
958
+ const patterns = [];
959
+ for (const rawLine of content.split(/\r?\n/)) {
960
+ const line = rawLine.trim();
961
+ if (line === "" || line.startsWith("#")) continue;
962
+ if (line.startsWith("!")) continue;
963
+ const rooted = line.startsWith("/");
964
+ const stripped = rooted ? line.slice(1) : line;
965
+ const isDirOnly = stripped.endsWith("/");
966
+ const core = isDirOnly ? stripped.slice(0, -1) : stripped;
967
+ if (core === "") continue;
968
+ if (rooted) {
969
+ patterns.push(isDirOnly ? `${core}/**` : core);
970
+ if (isDirOnly) patterns.push(`${core}/`);
971
+ } else {
972
+ patterns.push(`**/${core}`);
973
+ patterns.push(`**/${core}/**`);
974
+ if (isDirOnly) patterns.push(`**/${core}/`);
975
+ }
976
+ }
977
+ return patterns;
978
+ }
799
979
  var CodeIngestor = class {
800
- constructor(store) {
980
+ constructor(store, options = {}) {
801
981
  this.store = store;
982
+ this.skipDirs = resolveSkipDirs(options);
983
+ this.excludePatterns = options.excludePatterns ?? [];
984
+ this.respectGitignore = options.respectGitignore ?? true;
802
985
  }
803
986
  store;
987
+ skipDirs;
988
+ excludePatterns;
989
+ respectGitignore;
804
990
  async ingest(rootDir) {
805
991
  const start = Date.now();
806
992
  const errors = [];
@@ -890,19 +1076,55 @@ var CodeIngestor = class {
890
1076
  }
891
1077
  files.add(relativePath);
892
1078
  }
893
- async findSourceFiles(dir) {
1079
+ /**
1080
+ * Walk `rootDir` iteratively (BFS via an explicit queue) and return absolute paths
1081
+ * of every supported source file that is not excluded by the configured filters.
1082
+ *
1083
+ * Iterative on purpose: a recursive walker burns one stack frame per nested
1084
+ * directory and crashes with `Maximum call stack size exceeded` on deeply nested
1085
+ * monorepos (issue #274). The queue-based form keeps memory bounded by the
1086
+ * frontier size rather than the deepest path.
1087
+ */
1088
+ async findSourceFiles(rootDir) {
894
1089
  const results = [];
895
- const entries = await fs.readdir(dir, { withFileTypes: true });
896
- for (const entry of entries) {
897
- const fullPath = path.join(dir, entry.name);
898
- if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "dist" && entry.name !== "target" && entry.name !== "build" && entry.name !== ".git" && entry.name !== "__pycache__" && entry.name !== ".venv" && entry.name !== ".turbo" && entry.name !== ".harness" && entry.name !== ".next" && entry.name !== ".vscode" && entry.name !== ".idea" && entry.name !== "vendor" && entry.name !== "out" && entry.name !== ".gradle" && entry.name !== ".gradle-home" && entry.name !== "bin" && entry.name !== "obj" && entry.name !== "venv" && entry.name !== "_build" && entry.name !== "deps" && entry.name !== "coverage" && entry.name !== ".nyc_output") {
899
- results.push(...await this.findSourceFiles(fullPath));
900
- } else if (entry.isFile() && isSupportedSourceFile(entry.name)) {
901
- results.push(fullPath);
1090
+ const matchers = await this.buildExcludeMatchers(rootDir);
1091
+ const queue = [rootDir];
1092
+ while (queue.length > 0) {
1093
+ const dir = queue.pop();
1094
+ let entries;
1095
+ try {
1096
+ entries = await fs.readdir(dir, { withFileTypes: true });
1097
+ } catch {
1098
+ continue;
1099
+ }
1100
+ for (const entry of entries) {
1101
+ const fullPath = path.join(dir, entry.name);
1102
+ const relPath = path.relative(rootDir, fullPath).replaceAll("\\", "/");
1103
+ if (entry.isDirectory()) {
1104
+ if (this.skipDirs.has(entry.name)) continue;
1105
+ if (isExcluded(`${relPath}/`, matchers)) continue;
1106
+ queue.push(fullPath);
1107
+ } else if (entry.isFile()) {
1108
+ if (!isSupportedSourceFile(entry.name)) continue;
1109
+ if (isExcluded(relPath, matchers)) continue;
1110
+ results.push(fullPath);
1111
+ }
902
1112
  }
903
1113
  }
904
1114
  return results;
905
1115
  }
1116
+ /**
1117
+ * Compose the exclude matchers from `excludePatterns` and (optionally) `.gitignore`.
1118
+ * Returns a list of minimatch-compatible patterns; an empty list means "no excludes".
1119
+ */
1120
+ async buildExcludeMatchers(rootDir) {
1121
+ const patterns = [...this.excludePatterns];
1122
+ if (this.respectGitignore) {
1123
+ const gitignorePatterns = await readGitignorePatterns(rootDir);
1124
+ patterns.push(...gitignorePatterns);
1125
+ }
1126
+ return patterns;
1127
+ }
906
1128
  extractSymbols(content, fileId, relativePath) {
907
1129
  const results = [];
908
1130
  const lines = content.split("\n");
@@ -1672,6 +1894,7 @@ function emptyResult(durationMs = 0) {
1672
1894
 
1673
1895
  // src/ingest/KnowledgeIngestor.ts
1674
1896
  var CODE_NODE_TYPES = ["file", "function", "class", "method", "interface", "variable"];
1897
+ var DOCS_OWNED_BY_OTHER_INGESTORS = /* @__PURE__ */ new Set(["adr", "knowledge", "changes", "solutions"]);
1675
1898
  var KnowledgeIngestor = class {
1676
1899
  constructor(store) {
1677
1900
  this.store = store;
@@ -1743,15 +1966,53 @@ var KnowledgeIngestor = class {
1743
1966
  }
1744
1967
  return buildResult(nodesAdded, edgesAdded, [], start);
1745
1968
  }
1969
+ async ingestGeneralDocs(projectPath) {
1970
+ const start = Date.now();
1971
+ const errors = [];
1972
+ let nodesAdded = 0;
1973
+ let edgesAdded = 0;
1974
+ const files = /* @__PURE__ */ new Set();
1975
+ try {
1976
+ const entries = await fs2.readdir(projectPath, { withFileTypes: true });
1977
+ for (const entry of entries) {
1978
+ if (entry.isFile() && entry.name.endsWith(".md")) {
1979
+ files.add(path3.join(projectPath, entry.name));
1980
+ }
1981
+ }
1982
+ } catch {
1983
+ return emptyResult(Date.now() - start);
1984
+ }
1985
+ const docsRoot = path3.join(projectPath, "docs");
1986
+ try {
1987
+ const scanned = await this.scanDocsDir(docsRoot);
1988
+ for (const f of scanned) files.add(f);
1989
+ } catch {
1990
+ }
1991
+ for (const filePath of files) {
1992
+ try {
1993
+ const content = await fs2.readFile(filePath, "utf-8");
1994
+ const relPath = path3.relative(projectPath, filePath).replaceAll("\\", "/");
1995
+ const nodeId = `doc:${relPath}`;
1996
+ if (this.store.getNode(nodeId)) continue;
1997
+ this.store.addNode(parseDocumentNode(nodeId, filePath, relPath, content));
1998
+ nodesAdded++;
1999
+ edgesAdded += this.linkToCode(content, nodeId, "documents");
2000
+ } catch (err) {
2001
+ errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
2002
+ }
2003
+ }
2004
+ return buildResult(nodesAdded, edgesAdded, errors, start);
2005
+ }
1746
2006
  async ingestAll(projectPath, opts) {
1747
2007
  const start = Date.now();
1748
2008
  const adrDir = opts?.adrDir ?? path3.join(projectPath, "docs", "adr");
1749
- const [adrResult, learningsResult, failuresResult] = await Promise.all([
2009
+ const [adrResult, learningsResult, failuresResult, docsResult] = await Promise.all([
1750
2010
  this.ingestADRs(adrDir),
1751
2011
  this.ingestLearnings(projectPath),
1752
- this.ingestFailures(projectPath)
2012
+ this.ingestFailures(projectPath),
2013
+ this.ingestGeneralDocs(projectPath)
1753
2014
  ]);
1754
- const merged = mergeResults(adrResult, learningsResult, failuresResult);
2015
+ const merged = mergeResults(adrResult, learningsResult, failuresResult, docsResult);
1755
2016
  return { ...merged, durationMs: Date.now() - start };
1756
2017
  }
1757
2018
  linkToCode(content, sourceNodeId, edgeType) {
@@ -1786,7 +2047,22 @@ var KnowledgeIngestor = class {
1786
2047
  const entries = await fs2.readdir(dir, { withFileTypes: true });
1787
2048
  for (const entry of entries) {
1788
2049
  const fullPath = path3.join(dir, entry.name);
1789
- if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "dist" && entry.name !== "target" && entry.name !== "build" && entry.name !== ".git" && entry.name !== ".gradle" && entry.name !== ".harness" && entry.name !== "vendor" && entry.name !== "bin" && entry.name !== "obj" && entry.name !== "venv" && entry.name !== "_build" && entry.name !== "deps" && entry.name !== "coverage") {
2050
+ if (entry.isDirectory() && !DEFAULT_SKIP_DIRS.has(entry.name)) {
2051
+ results.push(...await this.findMarkdownFiles(fullPath));
2052
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
2053
+ results.push(fullPath);
2054
+ }
2055
+ }
2056
+ return results;
2057
+ }
2058
+ async scanDocsDir(docsRoot) {
2059
+ const results = [];
2060
+ const rootEntries = await fs2.readdir(docsRoot, { withFileTypes: true });
2061
+ for (const entry of rootEntries) {
2062
+ const fullPath = path3.join(docsRoot, entry.name);
2063
+ if (entry.isDirectory()) {
2064
+ if (DEFAULT_SKIP_DIRS.has(entry.name)) continue;
2065
+ if (DOCS_OWNED_BY_OTHER_INGESTORS.has(entry.name)) continue;
1790
2066
  results.push(...await this.findMarkdownFiles(fullPath));
1791
2067
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
1792
2068
  results.push(fullPath);
@@ -1812,6 +2088,17 @@ function buildResult(nodesAdded, edgesAdded, errors, start) {
1812
2088
  durationMs: Date.now() - start
1813
2089
  };
1814
2090
  }
2091
+ function parseDocumentNode(nodeId, filePath, relPath, content) {
2092
+ const titleMatch = content.match(/^#\s+(.+)$/m);
2093
+ const title = titleMatch ? titleMatch[1].trim() : path3.basename(filePath, ".md");
2094
+ return {
2095
+ id: nodeId,
2096
+ type: "document",
2097
+ name: title,
2098
+ path: filePath,
2099
+ metadata: { relPath }
2100
+ };
2101
+ }
1815
2102
  function parseADRNode(nodeId, filePath, filename, content) {
1816
2103
  const titleMatch = content.match(/^#\s+(.+)$/m);
1817
2104
  const title = titleMatch ? titleMatch[1].trim() : filename;
@@ -1867,6 +2154,47 @@ function parseFailureSection(section) {
1867
2154
  // src/ingest/BusinessKnowledgeIngestor.ts
1868
2155
  import * as fs3 from "fs/promises";
1869
2156
  import * as path4 from "path";
2157
+ import { z as z2 } from "zod";
2158
+ var BUG_TRACK_CATEGORIES = [
2159
+ "build-errors",
2160
+ "test-failures",
2161
+ "runtime-errors",
2162
+ "performance-issues",
2163
+ "database-issues",
2164
+ "security-issues",
2165
+ "ui-bugs",
2166
+ "integration-issues",
2167
+ "logic-errors"
2168
+ ];
2169
+ var KNOWLEDGE_TRACK_CATEGORIES = [
2170
+ "architecture-patterns",
2171
+ "design-patterns",
2172
+ "tooling-decisions",
2173
+ "conventions",
2174
+ "dx",
2175
+ "best-practices"
2176
+ ];
2177
+ var ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
2178
+ var SolutionBaseSchema = z2.object({
2179
+ module: z2.string().min(1),
2180
+ tags: z2.array(z2.string()),
2181
+ problem_type: z2.string().min(1),
2182
+ last_updated: z2.string().regex(ISO_DATE, "last_updated must be ISO date YYYY-MM-DD")
2183
+ });
2184
+ var SolutionDocFrontmatterSchema = z2.discriminatedUnion("track", [
2185
+ SolutionBaseSchema.merge(
2186
+ z2.object({
2187
+ track: z2.literal("bug-track"),
2188
+ category: z2.enum(BUG_TRACK_CATEGORIES)
2189
+ })
2190
+ ),
2191
+ SolutionBaseSchema.merge(
2192
+ z2.object({
2193
+ track: z2.literal("knowledge-track"),
2194
+ category: z2.enum(KNOWLEDGE_TRACK_CATEGORIES)
2195
+ })
2196
+ )
2197
+ ]);
1870
2198
  var BUSINESS_KNOWLEDGE_TYPES = /* @__PURE__ */ new Set([
1871
2199
  "business_rule",
1872
2200
  "business_process",
@@ -1909,6 +2237,65 @@ var BusinessKnowledgeIngestor = class {
1909
2237
  durationMs: Date.now() - start
1910
2238
  };
1911
2239
  }
2240
+ async ingestSolutions(solutionsDir) {
2241
+ const start = Date.now();
2242
+ const errors = [];
2243
+ let files;
2244
+ try {
2245
+ files = await this.findMarkdownFiles(solutionsDir);
2246
+ } catch {
2247
+ return emptyResult(Date.now() - start);
2248
+ }
2249
+ let nodesAdded = 0;
2250
+ for (const filePath of files) {
2251
+ try {
2252
+ const raw = await fs3.readFile(filePath, "utf-8");
2253
+ const parsed = parseSolutionFrontmatter(raw);
2254
+ if (!parsed) {
2255
+ errors.push(`${filePath}: no frontmatter found`);
2256
+ continue;
2257
+ }
2258
+ const validation = SolutionDocFrontmatterSchema.safeParse(parsed.frontmatter);
2259
+ if (!validation.success) {
2260
+ errors.push(`${filePath}: ${validation.error.message}`);
2261
+ continue;
2262
+ }
2263
+ if (validation.data.track !== "knowledge-track") continue;
2264
+ const relPath = path4.relative(solutionsDir, filePath).replaceAll("\\", "/");
2265
+ const filename = path4.basename(filePath, ".md");
2266
+ const nodeId = `bk:solutions:${validation.data.module}:${filename}`;
2267
+ const titleMatch = parsed.body.match(/^#\s+(.+)$/m);
2268
+ const name = titleMatch ? titleMatch[1].trim() : filename;
2269
+ const node = {
2270
+ id: nodeId,
2271
+ type: "business_concept",
2272
+ name,
2273
+ path: relPath,
2274
+ content: parsed.body.trim(),
2275
+ metadata: {
2276
+ domain: validation.data.module,
2277
+ tags: validation.data.tags,
2278
+ problem_type: validation.data.problem_type,
2279
+ last_updated: validation.data.last_updated,
2280
+ source: "solutions",
2281
+ category: validation.data.category
2282
+ }
2283
+ };
2284
+ this.store.addNode(node);
2285
+ nodesAdded++;
2286
+ } catch (err) {
2287
+ errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
2288
+ }
2289
+ }
2290
+ return {
2291
+ nodesAdded,
2292
+ nodesUpdated: 0,
2293
+ edgesAdded: 0,
2294
+ edgesUpdated: 0,
2295
+ errors,
2296
+ durationMs: Date.now() - start
2297
+ };
2298
+ }
1912
2299
  async createNodes(files, knowledgeDir, errors) {
1913
2300
  const entries = [];
1914
2301
  for (const filePath of files) {
@@ -1998,7 +2385,7 @@ var BusinessKnowledgeIngestor = class {
1998
2385
  const entries = await fs3.readdir(dir, { withFileTypes: true });
1999
2386
  for (const entry of entries) {
2000
2387
  const fullPath = path4.join(dir, entry.name);
2001
- if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "dist" && entry.name !== "target" && entry.name !== "build" && entry.name !== ".git" && entry.name !== ".gradle" && entry.name !== ".harness" && entry.name !== "vendor" && entry.name !== "bin" && entry.name !== "obj" && entry.name !== "venv" && entry.name !== "_build" && entry.name !== "deps" && entry.name !== "coverage") {
2388
+ if (entry.isDirectory() && !DEFAULT_SKIP_DIRS.has(entry.name)) {
2002
2389
  results.push(...await this.findMarkdownFiles(fullPath));
2003
2390
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
2004
2391
  results.push(fullPath);
@@ -2031,6 +2418,25 @@ function parseFrontmatter(raw) {
2031
2418
  body
2032
2419
  };
2033
2420
  }
2421
+ function parseSolutionFrontmatter(raw) {
2422
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
2423
+ if (!match) return null;
2424
+ const yamlBlock = match[1];
2425
+ const body = match[2];
2426
+ const frontmatter = {};
2427
+ for (const line of yamlBlock.split(/\r?\n/)) {
2428
+ const kvMatch = line.match(/^(\w+):\s*(.+)$/);
2429
+ if (!kvMatch) continue;
2430
+ const key = kvMatch[1];
2431
+ const value = kvMatch[2].trim();
2432
+ if (value.startsWith("[") && value.endsWith("]")) {
2433
+ frontmatter[key] = value.slice(1, -1).split(",").map((s) => s.trim());
2434
+ } else {
2435
+ frontmatter[key] = value;
2436
+ }
2437
+ }
2438
+ return { frontmatter, body };
2439
+ }
2034
2440
 
2035
2441
  // src/ingest/DecisionIngestor.ts
2036
2442
  import * as fs4 from "fs/promises";
@@ -2823,7 +3229,6 @@ var PlantUmlParser = class {
2823
3229
 
2824
3230
  // src/ingest/DiagramParser.ts
2825
3231
  var DIAGRAM_EXTENSIONS = /* @__PURE__ */ new Set([".mmd", ".mermaid", ".d2", ".puml", ".plantuml"]);
2826
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".harness"]);
2827
3232
  var DiagramParser = class {
2828
3233
  constructor(store) {
2829
3234
  this.store = store;
@@ -2928,7 +3333,7 @@ var DiagramParser = class {
2928
3333
  }
2929
3334
  for (const entry of entries) {
2930
3335
  if (entry.isDirectory()) {
2931
- if (!SKIP_DIRS.has(entry.name)) {
3336
+ if (!DEFAULT_SKIP_DIRS.has(entry.name)) {
2932
3337
  await walk(path7.join(currentDir, entry.name));
2933
3338
  }
2934
3339
  } else if (entry.isFile()) {
@@ -3452,31 +3857,6 @@ var KnowledgeStagingAggregator = class {
3452
3857
  // src/ingest/extractors/ExtractionRunner.ts
3453
3858
  import * as fs9 from "fs/promises";
3454
3859
  import * as path10 from "path";
3455
- var SKIP_DIRS2 = /* @__PURE__ */ new Set([
3456
- "node_modules",
3457
- "dist",
3458
- "target",
3459
- "build",
3460
- ".git",
3461
- "__pycache__",
3462
- ".venv",
3463
- ".turbo",
3464
- ".harness",
3465
- ".next",
3466
- ".vscode",
3467
- ".idea",
3468
- "vendor",
3469
- "out",
3470
- ".gradle",
3471
- ".gradle-home",
3472
- "bin",
3473
- "obj",
3474
- "venv",
3475
- "_build",
3476
- "deps",
3477
- "coverage",
3478
- ".nyc_output"
3479
- ]);
3480
3860
  var EXT_TO_LANGUAGE = {
3481
3861
  ".ts": "typescript",
3482
3862
  ".tsx": "typescript",
@@ -3655,7 +4035,7 @@ var ExtractionRunner = class {
3655
4035
  }
3656
4036
  for (const entry of entries) {
3657
4037
  const fullPath = path10.join(dir, entry.name);
3658
- if (entry.isDirectory() && !SKIP_DIRS2.has(entry.name)) {
4038
+ if (entry.isDirectory() && !DEFAULT_SKIP_DIRS.has(entry.name)) {
3659
4039
  results.push(...await this.findSourceFiles(fullPath));
3660
4040
  } else if (entry.isFile() && detectLanguage(fullPath) !== void 0) {
3661
4041
  results.push(fullPath);
@@ -5417,6 +5797,25 @@ var KnowledgePipelineRunner = class {
5417
5797
  durationMs: 0
5418
5798
  };
5419
5799
  }
5800
+ const solutionsDir = path12.join(options.projectDir, "docs", "solutions");
5801
+ let solutionsResult;
5802
+ try {
5803
+ solutionsResult = await bkIngestor.ingestSolutions(solutionsDir);
5804
+ } catch {
5805
+ solutionsResult = {
5806
+ nodesAdded: 0,
5807
+ nodesUpdated: 0,
5808
+ edgesAdded: 0,
5809
+ edgesUpdated: 0,
5810
+ errors: [],
5811
+ durationMs: 0
5812
+ };
5813
+ }
5814
+ bkResult = {
5815
+ ...bkResult,
5816
+ nodesAdded: bkResult.nodesAdded + solutionsResult.nodesAdded,
5817
+ errors: [...bkResult.errors, ...solutionsResult.errors]
5818
+ };
5420
5819
  const decisionsDir = path12.join(options.projectDir, "docs", "knowledge", "decisions");
5421
5820
  const decisionIngestor = new DecisionIngestor(this.store);
5422
5821
  let decisionResult;
@@ -8645,8 +9044,8 @@ function queryTraceability(store, options) {
8645
9044
  }
8646
9045
 
8647
9046
  // src/constraints/GraphConstraintAdapter.ts
8648
- import { minimatch } from "minimatch";
8649
- import { relative as relative5 } from "path";
9047
+ import { minimatch as minimatch2 } from "minimatch";
9048
+ import { relative as relative6 } from "path";
8650
9049
  var GraphConstraintAdapter = class {
8651
9050
  constructor(store) {
8652
9051
  this.store = store;
@@ -8675,8 +9074,8 @@ var GraphConstraintAdapter = class {
8675
9074
  const { edges } = this.computeDependencyGraph();
8676
9075
  const violations = [];
8677
9076
  for (const edge of edges) {
8678
- const fromRelative = relative5(rootDir, edge.from).replaceAll("\\", "/");
8679
- const toRelative = relative5(rootDir, edge.to).replaceAll("\\", "/");
9077
+ const fromRelative = relative6(rootDir, edge.from).replaceAll("\\", "/");
9078
+ const toRelative = relative6(rootDir, edge.to).replaceAll("\\", "/");
8680
9079
  const fromLayer = this.resolveLayer(fromRelative, layers);
8681
9080
  const toLayer = this.resolveLayer(toRelative, layers);
8682
9081
  if (!fromLayer || !toLayer) continue;
@@ -8697,7 +9096,7 @@ var GraphConstraintAdapter = class {
8697
9096
  resolveLayer(filePath, layers) {
8698
9097
  for (const layer of layers) {
8699
9098
  for (const pattern of layer.patterns) {
8700
- if (minimatch(filePath, pattern)) {
9099
+ if (minimatch2(filePath, pattern)) {
8701
9100
  return layer;
8702
9101
  }
8703
9102
  }
@@ -9496,6 +9895,7 @@ export {
9496
9895
  D2Parser,
9497
9896
  DEFAULT_BLOCKLIST,
9498
9897
  DEFAULT_PATTERNS,
9898
+ DEFAULT_SKIP_DIRS,
9499
9899
  DecisionIngestor,
9500
9900
  DesignConstraintAdapter,
9501
9901
  DesignIngestor,
@@ -9551,8 +9951,11 @@ export {
9551
9951
  inferDomain,
9552
9952
  linkToCode,
9553
9953
  loadGraph,
9954
+ loadGraphMetadata,
9554
9955
  normalizeIntent,
9555
9956
  project,
9556
9957
  queryTraceability,
9557
- saveGraph
9958
+ resolveSkipDirs,
9959
+ saveGraph,
9960
+ skipDirGlobs
9558
9961
  };