@harness-engineering/graph 0.7.1 → 0.8.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,84 @@ 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
+
868
+ // src/ingest/CodeIngestor.ts
730
869
  var SKIP_METHOD_NAMES = /* @__PURE__ */ new Set(["constructor", "if", "for", "while", "switch"]);
731
870
  var FUNCTION_PATTERNS = {
732
871
  python: /^def\s+(\w+)\s*\(/,
@@ -796,11 +935,55 @@ function isSupportedSourceFile(name) {
796
935
  const ext = name.slice(name.lastIndexOf("."));
797
936
  return SUPPORTED_EXTENSIONS.has(ext);
798
937
  }
938
+ function isExcluded(relPath, patterns) {
939
+ if (patterns.length === 0) return false;
940
+ for (const pattern of patterns) {
941
+ if (minimatch(relPath, pattern, { dot: true, matchBase: false })) return true;
942
+ if (!pattern.includes("/") && minimatch(relPath, pattern, { dot: true, matchBase: true })) {
943
+ return true;
944
+ }
945
+ }
946
+ return false;
947
+ }
948
+ async function readGitignorePatterns(rootDir) {
949
+ let content;
950
+ try {
951
+ content = await fs.readFile(path.join(rootDir, ".gitignore"), "utf-8");
952
+ } catch {
953
+ return [];
954
+ }
955
+ const patterns = [];
956
+ for (const rawLine of content.split(/\r?\n/)) {
957
+ const line = rawLine.trim();
958
+ if (line === "" || line.startsWith("#")) continue;
959
+ if (line.startsWith("!")) continue;
960
+ const rooted = line.startsWith("/");
961
+ const stripped = rooted ? line.slice(1) : line;
962
+ const isDirOnly = stripped.endsWith("/");
963
+ const core = isDirOnly ? stripped.slice(0, -1) : stripped;
964
+ if (core === "") continue;
965
+ if (rooted) {
966
+ patterns.push(isDirOnly ? `${core}/**` : core);
967
+ if (isDirOnly) patterns.push(`${core}/`);
968
+ } else {
969
+ patterns.push(`**/${core}`);
970
+ patterns.push(`**/${core}/**`);
971
+ if (isDirOnly) patterns.push(`**/${core}/`);
972
+ }
973
+ }
974
+ return patterns;
975
+ }
799
976
  var CodeIngestor = class {
800
- constructor(store) {
977
+ constructor(store, options = {}) {
801
978
  this.store = store;
979
+ this.skipDirs = resolveSkipDirs(options);
980
+ this.excludePatterns = options.excludePatterns ?? [];
981
+ this.respectGitignore = options.respectGitignore ?? true;
802
982
  }
803
983
  store;
984
+ skipDirs;
985
+ excludePatterns;
986
+ respectGitignore;
804
987
  async ingest(rootDir) {
805
988
  const start = Date.now();
806
989
  const errors = [];
@@ -890,19 +1073,55 @@ var CodeIngestor = class {
890
1073
  }
891
1074
  files.add(relativePath);
892
1075
  }
893
- async findSourceFiles(dir) {
1076
+ /**
1077
+ * Walk `rootDir` iteratively (BFS via an explicit queue) and return absolute paths
1078
+ * of every supported source file that is not excluded by the configured filters.
1079
+ *
1080
+ * Iterative on purpose: a recursive walker burns one stack frame per nested
1081
+ * directory and crashes with `Maximum call stack size exceeded` on deeply nested
1082
+ * monorepos (issue #274). The queue-based form keeps memory bounded by the
1083
+ * frontier size rather than the deepest path.
1084
+ */
1085
+ async findSourceFiles(rootDir) {
894
1086
  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);
1087
+ const matchers = await this.buildExcludeMatchers(rootDir);
1088
+ const queue = [rootDir];
1089
+ while (queue.length > 0) {
1090
+ const dir = queue.pop();
1091
+ let entries;
1092
+ try {
1093
+ entries = await fs.readdir(dir, { withFileTypes: true });
1094
+ } catch {
1095
+ continue;
1096
+ }
1097
+ for (const entry of entries) {
1098
+ const fullPath = path.join(dir, entry.name);
1099
+ const relPath = path.relative(rootDir, fullPath).replaceAll("\\", "/");
1100
+ if (entry.isDirectory()) {
1101
+ if (this.skipDirs.has(entry.name)) continue;
1102
+ if (isExcluded(`${relPath}/`, matchers)) continue;
1103
+ queue.push(fullPath);
1104
+ } else if (entry.isFile()) {
1105
+ if (!isSupportedSourceFile(entry.name)) continue;
1106
+ if (isExcluded(relPath, matchers)) continue;
1107
+ results.push(fullPath);
1108
+ }
902
1109
  }
903
1110
  }
904
1111
  return results;
905
1112
  }
1113
+ /**
1114
+ * Compose the exclude matchers from `excludePatterns` and (optionally) `.gitignore`.
1115
+ * Returns a list of minimatch-compatible patterns; an empty list means "no excludes".
1116
+ */
1117
+ async buildExcludeMatchers(rootDir) {
1118
+ const patterns = [...this.excludePatterns];
1119
+ if (this.respectGitignore) {
1120
+ const gitignorePatterns = await readGitignorePatterns(rootDir);
1121
+ patterns.push(...gitignorePatterns);
1122
+ }
1123
+ return patterns;
1124
+ }
906
1125
  extractSymbols(content, fileId, relativePath) {
907
1126
  const results = [];
908
1127
  const lines = content.split("\n");
@@ -1867,6 +2086,47 @@ function parseFailureSection(section) {
1867
2086
  // src/ingest/BusinessKnowledgeIngestor.ts
1868
2087
  import * as fs3 from "fs/promises";
1869
2088
  import * as path4 from "path";
2089
+ import { z as z2 } from "zod";
2090
+ var BUG_TRACK_CATEGORIES = [
2091
+ "build-errors",
2092
+ "test-failures",
2093
+ "runtime-errors",
2094
+ "performance-issues",
2095
+ "database-issues",
2096
+ "security-issues",
2097
+ "ui-bugs",
2098
+ "integration-issues",
2099
+ "logic-errors"
2100
+ ];
2101
+ var KNOWLEDGE_TRACK_CATEGORIES = [
2102
+ "architecture-patterns",
2103
+ "design-patterns",
2104
+ "tooling-decisions",
2105
+ "conventions",
2106
+ "dx",
2107
+ "best-practices"
2108
+ ];
2109
+ var ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
2110
+ var SolutionBaseSchema = z2.object({
2111
+ module: z2.string().min(1),
2112
+ tags: z2.array(z2.string()),
2113
+ problem_type: z2.string().min(1),
2114
+ last_updated: z2.string().regex(ISO_DATE, "last_updated must be ISO date YYYY-MM-DD")
2115
+ });
2116
+ var SolutionDocFrontmatterSchema = z2.discriminatedUnion("track", [
2117
+ SolutionBaseSchema.merge(
2118
+ z2.object({
2119
+ track: z2.literal("bug-track"),
2120
+ category: z2.enum(BUG_TRACK_CATEGORIES)
2121
+ })
2122
+ ),
2123
+ SolutionBaseSchema.merge(
2124
+ z2.object({
2125
+ track: z2.literal("knowledge-track"),
2126
+ category: z2.enum(KNOWLEDGE_TRACK_CATEGORIES)
2127
+ })
2128
+ )
2129
+ ]);
1870
2130
  var BUSINESS_KNOWLEDGE_TYPES = /* @__PURE__ */ new Set([
1871
2131
  "business_rule",
1872
2132
  "business_process",
@@ -1909,6 +2169,65 @@ var BusinessKnowledgeIngestor = class {
1909
2169
  durationMs: Date.now() - start
1910
2170
  };
1911
2171
  }
2172
+ async ingestSolutions(solutionsDir) {
2173
+ const start = Date.now();
2174
+ const errors = [];
2175
+ let files;
2176
+ try {
2177
+ files = await this.findMarkdownFiles(solutionsDir);
2178
+ } catch {
2179
+ return emptyResult(Date.now() - start);
2180
+ }
2181
+ let nodesAdded = 0;
2182
+ for (const filePath of files) {
2183
+ try {
2184
+ const raw = await fs3.readFile(filePath, "utf-8");
2185
+ const parsed = parseSolutionFrontmatter(raw);
2186
+ if (!parsed) {
2187
+ errors.push(`${filePath}: no frontmatter found`);
2188
+ continue;
2189
+ }
2190
+ const validation = SolutionDocFrontmatterSchema.safeParse(parsed.frontmatter);
2191
+ if (!validation.success) {
2192
+ errors.push(`${filePath}: ${validation.error.message}`);
2193
+ continue;
2194
+ }
2195
+ if (validation.data.track !== "knowledge-track") continue;
2196
+ const relPath = path4.relative(solutionsDir, filePath).replaceAll("\\", "/");
2197
+ const filename = path4.basename(filePath, ".md");
2198
+ const nodeId = `bk:solutions:${validation.data.module}:${filename}`;
2199
+ const titleMatch = parsed.body.match(/^#\s+(.+)$/m);
2200
+ const name = titleMatch ? titleMatch[1].trim() : filename;
2201
+ const node = {
2202
+ id: nodeId,
2203
+ type: "business_concept",
2204
+ name,
2205
+ path: relPath,
2206
+ content: parsed.body.trim(),
2207
+ metadata: {
2208
+ domain: validation.data.module,
2209
+ tags: validation.data.tags,
2210
+ problem_type: validation.data.problem_type,
2211
+ last_updated: validation.data.last_updated,
2212
+ source: "solutions",
2213
+ category: validation.data.category
2214
+ }
2215
+ };
2216
+ this.store.addNode(node);
2217
+ nodesAdded++;
2218
+ } catch (err) {
2219
+ errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
2220
+ }
2221
+ }
2222
+ return {
2223
+ nodesAdded,
2224
+ nodesUpdated: 0,
2225
+ edgesAdded: 0,
2226
+ edgesUpdated: 0,
2227
+ errors,
2228
+ durationMs: Date.now() - start
2229
+ };
2230
+ }
1912
2231
  async createNodes(files, knowledgeDir, errors) {
1913
2232
  const entries = [];
1914
2233
  for (const filePath of files) {
@@ -2031,6 +2350,25 @@ function parseFrontmatter(raw) {
2031
2350
  body
2032
2351
  };
2033
2352
  }
2353
+ function parseSolutionFrontmatter(raw) {
2354
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
2355
+ if (!match) return null;
2356
+ const yamlBlock = match[1];
2357
+ const body = match[2];
2358
+ const frontmatter = {};
2359
+ for (const line of yamlBlock.split(/\r?\n/)) {
2360
+ const kvMatch = line.match(/^(\w+):\s*(.+)$/);
2361
+ if (!kvMatch) continue;
2362
+ const key = kvMatch[1];
2363
+ const value = kvMatch[2].trim();
2364
+ if (value.startsWith("[") && value.endsWith("]")) {
2365
+ frontmatter[key] = value.slice(1, -1).split(",").map((s) => s.trim());
2366
+ } else {
2367
+ frontmatter[key] = value;
2368
+ }
2369
+ }
2370
+ return { frontmatter, body };
2371
+ }
2034
2372
 
2035
2373
  // src/ingest/DecisionIngestor.ts
2036
2374
  import * as fs4 from "fs/promises";
@@ -5417,6 +5755,25 @@ var KnowledgePipelineRunner = class {
5417
5755
  durationMs: 0
5418
5756
  };
5419
5757
  }
5758
+ const solutionsDir = path12.join(options.projectDir, "docs", "solutions");
5759
+ let solutionsResult;
5760
+ try {
5761
+ solutionsResult = await bkIngestor.ingestSolutions(solutionsDir);
5762
+ } catch {
5763
+ solutionsResult = {
5764
+ nodesAdded: 0,
5765
+ nodesUpdated: 0,
5766
+ edgesAdded: 0,
5767
+ edgesUpdated: 0,
5768
+ errors: [],
5769
+ durationMs: 0
5770
+ };
5771
+ }
5772
+ bkResult = {
5773
+ ...bkResult,
5774
+ nodesAdded: bkResult.nodesAdded + solutionsResult.nodesAdded,
5775
+ errors: [...bkResult.errors, ...solutionsResult.errors]
5776
+ };
5420
5777
  const decisionsDir = path12.join(options.projectDir, "docs", "knowledge", "decisions");
5421
5778
  const decisionIngestor = new DecisionIngestor(this.store);
5422
5779
  let decisionResult;
@@ -8645,7 +9002,7 @@ function queryTraceability(store, options) {
8645
9002
  }
8646
9003
 
8647
9004
  // src/constraints/GraphConstraintAdapter.ts
8648
- import { minimatch } from "minimatch";
9005
+ import { minimatch as minimatch2 } from "minimatch";
8649
9006
  import { relative as relative5 } from "path";
8650
9007
  var GraphConstraintAdapter = class {
8651
9008
  constructor(store) {
@@ -8697,7 +9054,7 @@ var GraphConstraintAdapter = class {
8697
9054
  resolveLayer(filePath, layers) {
8698
9055
  for (const layer of layers) {
8699
9056
  for (const pattern of layer.patterns) {
8700
- if (minimatch(filePath, pattern)) {
9057
+ if (minimatch2(filePath, pattern)) {
8701
9058
  return layer;
8702
9059
  }
8703
9060
  }
@@ -9496,6 +9853,7 @@ export {
9496
9853
  D2Parser,
9497
9854
  DEFAULT_BLOCKLIST,
9498
9855
  DEFAULT_PATTERNS,
9856
+ DEFAULT_SKIP_DIRS,
9499
9857
  DecisionIngestor,
9500
9858
  DesignConstraintAdapter,
9501
9859
  DesignIngestor,
@@ -9551,8 +9909,10 @@ export {
9551
9909
  inferDomain,
9552
9910
  linkToCode,
9553
9911
  loadGraph,
9912
+ loadGraphMetadata,
9554
9913
  normalizeIntent,
9555
9914
  project,
9556
9915
  queryTraceability,
9916
+ resolveSkipDirs,
9557
9917
  saveGraph
9558
9918
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-engineering/graph",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "description": "Knowledge graph for context assembly in Harness Engineering",
6
6
  "main": "./dist/index.js",