kibi-cli 0.2.3 → 0.2.5

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.
Files changed (95) hide show
  1. package/dist/cli.js +3 -28
  2. package/dist/commands/aggregated-checks.d.ts +4 -1
  3. package/dist/commands/aggregated-checks.d.ts.map +1 -1
  4. package/dist/commands/aggregated-checks.js +13 -3
  5. package/dist/commands/branch.d.ts.map +1 -1
  6. package/dist/commands/branch.js +3 -41
  7. package/dist/commands/check.d.ts.map +1 -1
  8. package/dist/commands/check.js +54 -44
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +0 -27
  11. package/dist/commands/gc.d.ts.map +1 -1
  12. package/dist/commands/gc.js +2 -51
  13. package/dist/commands/init-helpers.d.ts.map +1 -1
  14. package/dist/commands/init-helpers.js +23 -36
  15. package/dist/commands/init.d.ts.map +1 -1
  16. package/dist/commands/init.js +0 -27
  17. package/dist/commands/query.d.ts.map +1 -1
  18. package/dist/commands/query.js +7 -288
  19. package/dist/commands/sync/cache.d.ts +13 -0
  20. package/dist/commands/sync/cache.d.ts.map +1 -0
  21. package/dist/commands/sync/cache.js +76 -0
  22. package/dist/commands/sync/discovery.d.ts +8 -0
  23. package/dist/commands/sync/discovery.d.ts.map +1 -0
  24. package/dist/commands/sync/discovery.js +50 -0
  25. package/dist/commands/sync/extraction.d.ts +11 -0
  26. package/dist/commands/sync/extraction.d.ts.map +1 -0
  27. package/dist/commands/sync/extraction.js +69 -0
  28. package/dist/commands/sync/manifest.d.ts +5 -0
  29. package/dist/commands/sync/manifest.d.ts.map +1 -0
  30. package/dist/commands/sync/manifest.js +118 -0
  31. package/dist/commands/sync/persistence.d.ts +16 -0
  32. package/dist/commands/sync/persistence.d.ts.map +1 -0
  33. package/dist/commands/sync/persistence.js +188 -0
  34. package/dist/commands/sync/staging.d.ts +4 -0
  35. package/dist/commands/sync/staging.d.ts.map +1 -0
  36. package/dist/commands/sync/staging.js +86 -0
  37. package/dist/commands/sync.d.ts +2 -1
  38. package/dist/commands/sync.d.ts.map +1 -1
  39. package/dist/commands/sync.js +69 -501
  40. package/dist/extractors/manifest.d.ts +0 -1
  41. package/dist/extractors/manifest.d.ts.map +1 -1
  42. package/dist/extractors/manifest.js +39 -48
  43. package/dist/extractors/markdown.d.ts +0 -1
  44. package/dist/extractors/markdown.d.ts.map +1 -1
  45. package/dist/extractors/markdown.js +16 -49
  46. package/dist/extractors/relationships.d.ts +39 -0
  47. package/dist/extractors/relationships.d.ts.map +1 -0
  48. package/dist/extractors/relationships.js +137 -0
  49. package/dist/extractors/symbols-coordinator.d.ts.map +1 -1
  50. package/dist/extractors/symbols-coordinator.js +0 -27
  51. package/dist/extractors/symbols-ts.d.ts.map +1 -1
  52. package/dist/extractors/symbols-ts.js +0 -27
  53. package/dist/kb/target-resolver.d.ts +80 -0
  54. package/dist/kb/target-resolver.d.ts.map +1 -0
  55. package/dist/kb/target-resolver.js +313 -0
  56. package/dist/prolog/codec.d.ts +63 -0
  57. package/dist/prolog/codec.d.ts.map +1 -0
  58. package/dist/prolog/codec.js +434 -0
  59. package/dist/prolog.d.ts.map +1 -1
  60. package/dist/prolog.js +0 -27
  61. package/dist/public/extractors/symbols-coordinator.d.ts.map +1 -1
  62. package/dist/public/extractors/symbols-coordinator.js +0 -27
  63. package/dist/public/prolog/index.d.ts.map +1 -1
  64. package/dist/public/prolog/index.js +0 -27
  65. package/dist/public/schemas/entity.d.ts.map +1 -1
  66. package/dist/public/schemas/entity.js +0 -27
  67. package/dist/public/schemas/relationship.d.ts.map +1 -1
  68. package/dist/public/schemas/relationship.js +0 -27
  69. package/dist/query/service.d.ts +35 -0
  70. package/dist/query/service.d.ts.map +1 -0
  71. package/dist/query/service.js +149 -0
  72. package/dist/relationships/shards.d.ts +68 -0
  73. package/dist/relationships/shards.d.ts.map +1 -0
  74. package/dist/relationships/shards.js +263 -0
  75. package/dist/traceability/git-staged.d.ts +4 -1
  76. package/dist/traceability/git-staged.d.ts.map +1 -1
  77. package/dist/traceability/git-staged.js +24 -11
  78. package/dist/types/changeset.d.ts.map +1 -1
  79. package/dist/types/entities.d.ts.map +1 -1
  80. package/dist/types/relationships.d.ts.map +1 -1
  81. package/dist/utils/branch-resolver.d.ts +4 -0
  82. package/dist/utils/branch-resolver.d.ts.map +1 -1
  83. package/dist/utils/branch-resolver.js +4 -0
  84. package/dist/utils/config.d.ts +10 -1
  85. package/dist/utils/config.d.ts.map +1 -1
  86. package/dist/utils/config.js +27 -1
  87. package/dist/utils/rule-registry.d.ts +47 -0
  88. package/dist/utils/rule-registry.d.ts.map +1 -0
  89. package/dist/utils/rule-registry.js +139 -0
  90. package/package.json +5 -1
  91. package/schema/config.json +156 -0
  92. package/src/public/extractors/symbols-coordinator.ts +0 -27
  93. package/src/public/prolog/index.ts +0 -27
  94. package/src/public/schemas/entity.ts +0 -27
  95. package/src/public/schemas/relationship.ts +0 -27
@@ -14,213 +14,33 @@
14
14
 
15
15
  You should have received a copy of the GNU Affero General Public License
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
- */
18
- /*
19
- How to apply this header to source files (examples)
20
-
21
- 1) Prepend header to a single file (POSIX shells):
22
-
23
- cat LICENSE_HEADER.txt "$FILE" > "$FILE".with-header && mv "$FILE".with-header "$FILE"
24
-
25
- 2) Apply to multiple files (example: the project's main entry files):
26
-
27
- for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp packages/cli/src/*.ts packages/mcp/src/*.ts; do
28
- if [ -f "$f" ]; then
29
- cp "$f" "$f".bak
30
- (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
31
- fi
32
- done
33
-
34
- 3) Avoid duplicating the header: run a quick guard to only add if missing
35
-
36
- for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp; do
37
- if [ -f "$f" ]; then
38
- if ! head -n 5 "$f" | grep -q "Copyright (C) 2026 Piotr Franczyk"; then
39
- cp "$f" "$f".bak
40
- (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
41
- fi
42
- fi
43
- done
44
- */
17
+ */
45
18
  import { execSync } from "node:child_process";
46
- import { createHash } from "node:crypto";
47
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
19
+ import { existsSync } from "node:fs";
48
20
  import * as path from "node:path";
49
- import fg from "fast-glob";
50
- import { dump as dumpYAML, load as parseYAML } from "js-yaml";
51
- import { extractFromManifest } from "../extractors/manifest.js";
52
- import { FrontmatterError, extractFromMarkdown, } from "../extractors/markdown.js";
53
- import { copyFileSync } from "node:fs";
54
21
  import { branchErrorToDiagnostic, createDocsNotIndexedDiagnostic, createInvalidAuthoringDiagnostic, createKbMissingDiagnostic, formatSyncSummary, } from "../diagnostics.js";
55
- import { enrichSymbolCoordinates, } from "../extractors/symbols-coordinator.js";
22
+ import { extractFromRelationshipShards, flattenRelationships, validateRelationships, } from "../extractors/relationships.js";
56
23
  import { PrologProcess } from "../prolog.js";
57
- import { copyCleanSnapshot, getBranchDiagnostic, resolveActiveBranch, resolveDefaultBranch, } from "../utils/branch-resolver.js";
24
+ import { resolveActiveBranch, } from "../utils/branch-resolver.js";
58
25
  import { loadSyncConfig } from "../utils/config.js";
26
+ import { SYNC_CACHE_TTL_MS, SYNC_CACHE_VERSION, hashFile, readSyncCache, toCacheKey, writeSyncCache, } from "./sync/cache.js";
27
+ import { discoverSourceFiles, } from "./sync/discovery.js";
28
+ import { processExtractions } from "./sync/extraction.js";
29
+ import { refreshManifestCoordinates } from "./sync/manifest.js";
30
+ import { persistEntities, persistRelationships } from "./sync/persistence.js";
31
+ import { atomicPublish, cleanupStaging, prepareStagingEnvironment, } from "./sync/staging.js";
59
32
  export class SyncError extends Error {
60
33
  constructor(message) {
61
34
  super(message);
62
35
  this.name = "SyncError";
63
36
  }
64
37
  }
65
- const SYNC_CACHE_VERSION = 1;
66
- const SYNC_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
67
- const SYMBOLS_MANIFEST_COMMENT_BLOCK = `# symbols.yaml
68
- # AUTHORED fields (edit freely):
69
- # id, title, sourceFile, links, status, tags, owner, priority
70
- # GENERATED fields (never edit manually — overwritten by kibi sync and kb.symbols.refresh):
71
- # sourceLine, sourceColumn, sourceEndLine, sourceEndColumn, coordinatesGeneratedAt
72
- # Run \`kibi sync\` or call the \`kb.symbols.refresh\` MCP tool to refresh coordinates.
73
- `;
74
- const SYMBOL_COORD_EXTENSIONS = new Set([
75
- ".ts",
76
- ".tsx",
77
- ".js",
78
- ".jsx",
79
- ".mts",
80
- ".cts",
81
- ".mjs",
82
- ".cjs",
83
- ]);
84
- const GENERATED_COORD_FIELDS = [
85
- "sourceLine",
86
- "sourceColumn",
87
- "sourceEndLine",
88
- "sourceEndColumn",
89
- "coordinatesGeneratedAt",
90
- ];
91
- function toCacheKey(filePath) {
92
- return path.relative(process.cwd(), filePath).split(path.sep).join("/");
93
- }
94
- function hashFile(filePath) {
95
- const content = readFileSync(filePath);
96
- return createHash("sha256").update(content).digest("hex");
97
- }
98
- function readSyncCache(cachePath) {
99
- if (!existsSync(cachePath)) {
100
- return {
101
- version: SYNC_CACHE_VERSION,
102
- hashes: {},
103
- seenAt: {},
104
- };
105
- }
106
- try {
107
- const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
108
- if (parsed.version !== SYNC_CACHE_VERSION) {
109
- return {
110
- version: SYNC_CACHE_VERSION,
111
- hashes: {},
112
- seenAt: {},
113
- };
114
- }
115
- return {
116
- version: SYNC_CACHE_VERSION,
117
- hashes: parsed.hashes ?? {},
118
- seenAt: parsed.seenAt ?? {},
119
- };
120
- }
121
- catch {
122
- return {
123
- version: SYNC_CACHE_VERSION,
124
- hashes: {},
125
- seenAt: {},
126
- };
127
- }
128
- }
129
- function writeSyncCache(cachePath, cache) {
130
- const cacheDir = path.dirname(cachePath);
131
- if (!existsSync(cacheDir)) {
132
- mkdirSync(cacheDir, { recursive: true });
133
- }
134
- writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}
135
- `, "utf8");
136
- }
137
- function copySyncCache(livePath, stagingPath) {
138
- const liveCachePath = path.join(livePath, "sync-cache.json");
139
- const stagingCachePath = path.join(stagingPath, "sync-cache.json");
140
- if (existsSync(liveCachePath)) {
141
- const cacheContent = readFileSync(liveCachePath, "utf8");
142
- writeFileSync(stagingCachePath, cacheContent, "utf8");
143
- }
144
- }
145
- async function copySchemaToStaging(stagingPath) {
146
- const possibleSchemaPaths = [
147
- path.resolve(process.cwd(), "node_modules", "kibi-cli", "schema"),
148
- path.resolve(process.cwd(), "..", "..", "schema"),
149
- path.resolve(import.meta.dirname || __dirname, "..", "..", "schema"),
150
- path.resolve(process.cwd(), "packages", "cli", "schema"),
151
- ];
152
- let schemaSourceDir = null;
153
- for (const p of possibleSchemaPaths) {
154
- if (existsSync(p)) {
155
- schemaSourceDir = p;
156
- break;
157
- }
158
- }
159
- if (!schemaSourceDir) {
160
- return;
161
- }
162
- const schemaFiles = await fg("*.pl", {
163
- cwd: schemaSourceDir,
164
- absolute: false,
165
- });
166
- const schemaDestDir = path.join(stagingPath, "schema");
167
- if (!existsSync(schemaDestDir)) {
168
- mkdirSync(schemaDestDir, { recursive: true });
169
- }
170
- for (const file of schemaFiles) {
171
- const sourcePath = path.join(schemaSourceDir, file);
172
- const destPath = path.join(schemaDestDir, file);
173
- copyFileSync(sourcePath, destPath);
174
- }
175
- }
176
- async function validateStagingKB(stagingPath) {
177
- const prolog = new PrologProcess({ timeout: 60000 });
178
- await prolog.start();
179
- try {
180
- const attachResult = await prolog.query(`kb_attach('${stagingPath}')`);
181
- if (!attachResult.success) {
182
- console.error(`Failed to attach to staging KB: ${attachResult.error}`);
183
- return false;
184
- }
185
- await prolog.query("kb_detach");
186
- return true;
187
- }
188
- catch (error) {
189
- const message = error instanceof Error ? error.message : String(error);
190
- console.error(`Validation error: ${message}`);
191
- return false;
192
- }
193
- finally {
194
- await prolog.terminate();
195
- }
196
- }
197
- function atomicPublish(stagingPath, livePath) {
198
- const liveParent = path.dirname(livePath);
199
- if (!existsSync(liveParent)) {
200
- mkdirSync(liveParent, { recursive: true });
201
- }
202
- if (existsSync(livePath)) {
203
- const tempPath = `${livePath}.old.${Date.now()}`;
204
- renameSync(livePath, tempPath);
205
- renameSync(stagingPath, livePath);
206
- rmSync(tempPath, { recursive: true, force: true });
207
- }
208
- else {
209
- renameSync(stagingPath, livePath);
210
- }
211
- }
212
- function cleanupStaging(stagingPath) {
213
- if (existsSync(stagingPath)) {
214
- rmSync(stagingPath, { recursive: true, force: true });
215
- }
216
- }
217
38
  export async function syncCommand(options = {}) {
218
39
  const validateOnly = options.validateOnly ?? false;
219
40
  const rebuild = options.rebuild ?? false;
220
41
  const startTime = Date.now();
221
42
  const diagnostics = [];
222
43
  const entityCounts = {};
223
- const relationshipCount = 0;
224
44
  let published = false;
225
45
  let currentBranch;
226
46
  const getCurrentCommit = () => {
@@ -237,11 +57,12 @@ export async function syncCommand(options = {}) {
237
57
  }
238
58
  };
239
59
  try {
60
+ // Branch resolution
240
61
  const branchResult = resolveActiveBranch(process.cwd());
241
62
  if ("error" in branchResult) {
242
63
  const diagnostic = branchErrorToDiagnostic(branchResult.code, branchResult.error);
243
64
  diagnostics.push(diagnostic);
244
- console.error(getBranchDiagnostic(undefined, branchResult.error));
65
+ console.error(`Failed to resolve active branch: ${branchResult.error}`);
245
66
  throw new SyncError(`Failed to resolve active branch: ${branchResult.error}`);
246
67
  }
247
68
  currentBranch = branchResult.branch;
@@ -252,61 +73,33 @@ export async function syncCommand(options = {}) {
252
73
  }
253
74
  catch { }
254
75
  }
255
- // Load config using shared loader
76
+ // Load config
256
77
  const config = loadSyncConfig(process.cwd());
257
78
  const paths = config.paths;
258
- // Discover files - construct glob patterns from directory paths
259
- const normalizeMarkdownPath = (pattern) => {
260
- if (!pattern)
261
- return null;
262
- if (pattern.includes("*"))
263
- return pattern;
264
- return `${pattern}/**/*.md`;
265
- };
266
- const markdownPatterns = [
267
- normalizeMarkdownPath(paths.requirements),
268
- normalizeMarkdownPath(paths.scenarios),
269
- normalizeMarkdownPath(paths.tests),
270
- normalizeMarkdownPath(paths.adr),
271
- normalizeMarkdownPath(paths.flags),
272
- normalizeMarkdownPath(paths.events),
273
- normalizeMarkdownPath(paths.facts),
274
- ].filter((p) => Boolean(p));
275
- const markdownFiles = await fg(markdownPatterns, {
276
- cwd: process.cwd(),
277
- absolute: true,
278
- });
79
+ // File discovery
80
+ const { markdownFiles, manifestFiles, relationshipsDir } = await discoverSourceFiles(process.cwd(), paths);
279
81
  if (process.env.KIBI_DEBUG) {
280
82
  try {
281
83
  // eslint-disable-next-line no-console
282
- console.log("[kibi-debug] markdownPatterns:", markdownPatterns);
84
+ console.log("[kibi-debug] markdownFiles:", markdownFiles.length);
283
85
  // eslint-disable-next-line no-console
284
- console.log("[kibi-debug] markdownFiles:", markdownFiles);
86
+ console.log("[kibi-debug] manifestFiles:", manifestFiles.length);
285
87
  }
286
88
  catch { }
287
89
  }
288
- const manifestFiles = paths.symbols
289
- ? await fg(paths.symbols, {
290
- cwd: process.cwd(),
291
- absolute: true,
292
- })
293
- : [];
294
90
  const sourceFiles = [...markdownFiles, ...manifestFiles].sort();
295
- // Use branch-specific cache to handle branch isolation correctly
296
91
  const cachePath = path.join(process.cwd(), `.kb/branches/${currentBranch}/sync-cache.json`);
297
92
  const syncCache = readSyncCache(cachePath);
298
93
  const nowIso = new Date().toISOString();
299
94
  const nowMs = Date.now();
300
95
  const nextHashes = {};
301
96
  const nextSeenAt = {};
97
+ // Extract relationships from shard files
98
+ const shardResults = extractFromRelationshipShards(relationshipsDir);
99
+ const allRelationships = flattenRelationships(shardResults);
302
100
  const changedMarkdownFiles = [];
303
101
  const changedManifestFiles = [];
304
- if (process.env.KIBI_DEBUG) {
305
- // eslint-disable-next-line no-console
306
- console.log("[kibi-debug] sourceFiles:", sourceFiles);
307
- // eslint-disable-next-line no-console
308
- console.log("[kibi-debug] syncCache.hashes:", syncCache.hashes);
309
- }
102
+ // Detect changed files
310
103
  for (const file of sourceFiles) {
311
104
  try {
312
105
  const key = toCacheKey(file);
@@ -318,11 +111,7 @@ export async function syncCommand(options = {}) {
318
111
  : nowMs - lastSeenMs > SYNC_CACHE_TTL_MS;
319
112
  nextHashes[key] = hash;
320
113
  nextSeenAt[key] = nowIso;
321
- const isChanged = expired || syncCache.hashes[key] !== hash || validateOnly;
322
- if (process.env.KIBI_DEBUG) {
323
- // eslint-disable-next-line no-console
324
- console.log(`[kibi-debug] ${key}: cached=${syncCache.hashes[key]}, current=${hash}, changed=${isChanged}`);
325
- }
114
+ const isChanged = expired || syncCache.hashes[key] !== hash || validateOnly || rebuild;
326
115
  if (isChanged) {
327
116
  if (markdownFiles.includes(file)) {
328
117
  changedMarkdownFiles.push(file);
@@ -337,54 +126,22 @@ export async function syncCommand(options = {}) {
337
126
  console.warn(`Warning: Failed to hash ${file}: ${message}`);
338
127
  }
339
128
  }
340
- if (process.env.KIBI_DEBUG) {
341
- // eslint-disable-next-line no-console
342
- console.log("[kibi-debug] changedMarkdownFiles:", changedMarkdownFiles);
343
- }
344
- const results = [];
345
- const failedCacheKeys = new Set();
346
- const errors = [];
347
- for (const file of changedMarkdownFiles) {
348
- try {
349
- results.push(extractFromMarkdown(file));
350
- }
351
- catch (error) {
352
- const message = error instanceof Error ? error.message : String(error);
353
- // Handle INVALID_AUTHORING diagnostics for embedded entities
354
- if (error instanceof FrontmatterError &&
355
- error.classification === "Embedded Entity Violation") {
356
- const embeddedTypes = message.includes("scenario") && message.includes("test")
357
- ? ["scenario", "test"]
358
- : message.includes("scenario")
359
- ? ["scenario"]
360
- : message.includes("test")
361
- ? ["test"]
362
- : ["entity"];
363
- diagnostics.push(createInvalidAuthoringDiagnostic(file, embeddedTypes));
364
- }
365
- if (validateOnly) {
366
- errors.push({ file, message });
367
- }
368
- else {
369
- console.warn(`Warning: Failed to extract from ${file}: ${message}`);
370
- }
371
- failedCacheKeys.add(toCacheKey(file));
372
- }
373
- }
374
- for (const file of changedManifestFiles) {
375
- try {
376
- const manifestResults = extractFromManifest(file);
377
- results.push(...manifestResults);
378
- }
379
- catch (error) {
380
- const message = error instanceof Error ? error.message : String(error);
381
- if (validateOnly) {
382
- errors.push({ file, message });
383
- }
384
- else {
385
- console.warn(`Warning: Failed to extract from ${file}: ${message}`);
386
- }
387
- failedCacheKeys.add(toCacheKey(file));
129
+ // Content extraction
130
+ const { results, failedCacheKeys, errors } = await processExtractions(changedMarkdownFiles, changedManifestFiles, validateOnly);
131
+ // Collect INVALID_AUTHORING diagnostics
132
+ for (const err of errors) {
133
+ const error = new Error(err.message);
134
+ if (err.message.includes("Embedded Entity Violation") ||
135
+ err.message.includes("scenario") ||
136
+ err.message.includes("test")) {
137
+ const embeddedTypes = err.message.includes("scenario") && err.message.includes("test")
138
+ ? ["scenario", "test"]
139
+ : err.message.includes("scenario")
140
+ ? ["scenario"]
141
+ : err.message.includes("test")
142
+ ? ["test"]
143
+ : ["entity"];
144
+ diagnostics.push(createInvalidAuthoringDiagnostic(err.file, embeddedTypes));
388
145
  }
389
146
  }
390
147
  if (validateOnly) {
@@ -400,6 +157,7 @@ export async function syncCommand(options = {}) {
400
157
  process.exit(0);
401
158
  }
402
159
  }
160
+ // Refresh symbol manifest coordinates
403
161
  for (const file of manifestFiles) {
404
162
  try {
405
163
  await refreshManifestCoordinates(file, process.cwd());
@@ -409,7 +167,8 @@ export async function syncCommand(options = {}) {
409
167
  console.warn(`Warning: Failed to refresh symbol coordinates in ${file}: ${message}`);
410
168
  }
411
169
  }
412
- if (results.length === 0 && !rebuild) {
170
+ // Early exit if no changes
171
+ if (results.length === 0 && allRelationships.length === 0 && !rebuild) {
413
172
  const evictedHashes = {};
414
173
  const evictedSeenAt = {};
415
174
  for (const [key, hash] of Object.entries(nextHashes)) {
@@ -417,7 +176,7 @@ export async function syncCommand(options = {}) {
417
176
  continue;
418
177
  }
419
178
  evictedHashes[key] = hash;
420
- evictedSeenAt[key] = nextSeenAt[key] ?? nowIso;
179
+ evictedSeenAt[key] = nextHashes[key] ?? nowIso;
421
180
  }
422
181
  writeSyncCache(cachePath, {
423
182
  version: SYNC_CACHE_VERSION,
@@ -427,37 +186,16 @@ export async function syncCommand(options = {}) {
427
186
  console.log("✓ Imported 0 entities, 0 relationships (no changes)");
428
187
  process.exit(0);
429
188
  }
189
+ // Staging setup
430
190
  const livePath = path.join(process.cwd(), `.kb/branches/${currentBranch}`);
431
- // Check if KB exists (for diagnostic purposes)
432
191
  const kbExists = existsSync(livePath);
433
192
  if (!kbExists && !rebuild) {
434
193
  diagnostics.push(createKbMissingDiagnostic(currentBranch, livePath));
435
194
  }
436
195
  const stagingPath = path.join(process.cwd(), `.kb/branches/${currentBranch}.staging`);
437
- cleanupStaging(stagingPath);
438
- mkdirSync(stagingPath, { recursive: true });
196
+ await prepareStagingEnvironment(stagingPath, livePath, rebuild);
197
+ // Persistence to KB
439
198
  try {
440
- if (!rebuild) {
441
- const config = loadSyncConfig(process.cwd());
442
- const defaultBranchResult = resolveDefaultBranch(process.cwd(), config);
443
- const defaultBranch = "branch" in defaultBranchResult ? defaultBranchResult.branch : "main";
444
- const defaultBranchPath = path.join(process.cwd(), `.kb/branches/${defaultBranch}`);
445
- const sourcePath = existsSync(livePath)
446
- ? livePath
447
- : existsSync(defaultBranchPath) && currentBranch !== defaultBranch
448
- ? defaultBranchPath
449
- : null;
450
- if (sourcePath) {
451
- copyCleanSnapshot(sourcePath, stagingPath);
452
- copySyncCache(sourcePath, stagingPath);
453
- }
454
- else {
455
- await copySchemaToStaging(stagingPath);
456
- }
457
- }
458
- else {
459
- await copySchemaToStaging(stagingPath);
460
- }
461
199
  const prolog = new PrologProcess({ timeout: 120000 });
462
200
  await prolog.start();
463
201
  const attachResult = await prolog.query(`kb_attach('${stagingPath}')`);
@@ -465,133 +203,35 @@ export async function syncCommand(options = {}) {
465
203
  await prolog.terminate();
466
204
  throw new SyncError(`Failed to attach to staging KB: ${attachResult.error || "Unknown error"}`);
467
205
  }
468
- let entityCount = 0;
469
- let kbModified = false;
470
- // Track entity counts by type
471
- for (const { entity } of results) {
472
- entityCounts[entity.type] = (entityCounts[entity.type] || 0) + 1;
473
- }
474
- const simplePrologAtom = /^[a-z][a-zA-Z0-9_]*$/;
475
- const prologAtom = (value) => simplePrologAtom.test(value) ? value : `'${value.replace(/'/g, "''")}'`;
476
- for (const { entity } of results) {
477
- try {
478
- const props = [
479
- `id='${entity.id}'`,
480
- `title="${entity.title.replace(/"/g, '\\"')}"`,
481
- `status=${prologAtom(entity.status)}`,
482
- `created_at="${entity.created_at}"`,
483
- `updated_at="${entity.updated_at}"`,
484
- `source="${entity.source.replace(/"/g, '\\"')}"`,
485
- ];
486
- if (entity.tags && entity.tags.length > 0) {
487
- const tagsList = entity.tags.map(prologAtom).join(",");
488
- props.push(`tags=[${tagsList}]`);
489
- }
490
- if (entity.owner)
491
- props.push(`owner=${prologAtom(entity.owner)}`);
492
- if (entity.priority)
493
- props.push(`priority=${prologAtom(entity.priority)}`);
494
- if (entity.severity)
495
- props.push(`severity=${prologAtom(entity.severity)}`);
496
- if (entity.text_ref)
497
- props.push(`text_ref="${entity.text_ref}"`);
498
- const propsList = `[${props.join(", ")}]`;
499
- const goal = `kb_assert_entity(${entity.type}, ${propsList})`;
500
- const result = await prolog.query(goal);
501
- if (result.success) {
502
- entityCount++;
503
- kbModified = true;
504
- }
505
- }
506
- catch (error) {
507
- const message = error instanceof Error ? error.message : String(error);
508
- console.warn(`Warning: Failed to upsert entity ${entity.id}: ${message}`);
206
+ const entityIds = new Set();
207
+ // Validate and filter dangling relationships
208
+ const validationErrors = validateRelationships(allRelationships, entityIds);
209
+ if (validationErrors.length > 0) {
210
+ console.warn(`Warning: ${validationErrors.length} dangling relationship(s) found`);
211
+ for (const { relationship, error } of validationErrors) {
212
+ console.warn(` - ${error}: ${relationship.type} ${relationship.from} -> ${relationship.to}`);
509
213
  }
510
214
  }
511
- const idLookup = new Map();
215
+ const danglingKeys = new Set(validationErrors.map(({ relationship: r }) => `${r.type}|${r.from}|${r.to}`));
216
+ const validRelationships = allRelationships.filter((r) => !danglingKeys.has(`${r.type}|${r.from}|${r.to}`));
217
+ // Track entity counts by type
512
218
  for (const { entity } of results) {
513
- const filename = path.basename(entity.source, ".md");
514
- idLookup.set(filename, entity.id);
515
- idLookup.set(entity.id, entity.id);
516
- }
517
- let relCount = 0;
518
- const failedRelationships = [];
519
- for (const { relationships } of results) {
520
- for (const rel of relationships) {
521
- try {
522
- const fromId = idLookup.get(rel.from) || rel.from;
523
- const toId = idLookup.get(rel.to) || rel.to;
524
- const goal = `kb_assert_relationship(${rel.type}, '${fromId}', '${toId}', [])`;
525
- const result = await prolog.query(goal);
526
- if (result.success) {
527
- relCount++;
528
- kbModified = true;
529
- }
530
- else {
531
- failedRelationships.push({
532
- rel,
533
- fromId,
534
- toId,
535
- error: result.error || "Unknown error",
536
- });
537
- }
538
- }
539
- catch (error) {
540
- const message = error instanceof Error ? error.message : String(error);
541
- const fromId = idLookup.get(rel.from) || rel.from;
542
- const toId = idLookup.get(rel.to) || rel.to;
543
- failedRelationships.push({ rel, fromId, toId, error: message });
544
- }
545
- }
546
- }
547
- const retryCount = 3;
548
- for (let pass = 0; pass < retryCount && failedRelationships.length > 0; pass++) {
549
- const remainingFailed = [];
550
- for (const { rel, fromId, toId } of failedRelationships) {
551
- try {
552
- const goal = `kb_assert_relationship(${rel.type}, '${fromId}', '${toId}', [])`;
553
- const result = await prolog.query(goal);
554
- if (result.success) {
555
- relCount++;
556
- kbModified = true;
557
- }
558
- else {
559
- remainingFailed.push({
560
- rel,
561
- fromId,
562
- toId,
563
- error: result.error || "Unknown error",
564
- });
565
- }
566
- }
567
- catch (err) {
568
- const message = err instanceof Error ? err.message : String(err);
569
- remainingFailed.push({ rel, fromId, toId, error: message });
570
- }
571
- }
572
- failedRelationships.length = 0;
573
- failedRelationships.push(...remainingFailed);
574
- }
575
- if (failedRelationships.length > 0) {
576
- console.warn(`\nWarning: ${failedRelationships.length} relationship(s) failed to sync:`);
577
- const seen = new Set();
578
- for (const { rel, fromId, toId, error } of failedRelationships) {
579
- const key = `${rel.type}:${fromId}->${toId}`;
580
- if (!seen.has(key)) {
581
- seen.add(key);
582
- console.warn(` - ${rel.type}: ${fromId} -> ${toId}`);
583
- console.warn(` Error: ${error}`);
584
- }
585
- }
586
- console.warn("\nTip: Ensure target entities exist before creating relationships.");
219
+ entityCounts[entity.type] = (entityCounts[entity.type] || 0) + 1;
587
220
  }
221
+ // Persist entities
222
+ const { entityCount, kbModified: entitiesModified } = await persistEntities(prolog, results, entityIds);
223
+ // Persist relationships
224
+ const { relationshipCount, kbModified: relationshipsModified } = await persistRelationships(prolog, results, validRelationships);
225
+ const kbModified = entitiesModified || relationshipsModified;
588
226
  if (kbModified) {
589
227
  prolog.invalidateCache();
590
228
  }
591
229
  await prolog.query("kb_save");
592
230
  await prolog.query("kb_detach");
593
231
  await prolog.terminate();
232
+ // Publish staging to live
594
233
  atomicPublish(stagingPath, livePath);
234
+ // Update cache
595
235
  const evictedHashes = {};
596
236
  const evictedSeenAt = {};
597
237
  for (const [key, hash] of Object.entries(nextHashes)) {
@@ -599,7 +239,7 @@ export async function syncCommand(options = {}) {
599
239
  continue;
600
240
  }
601
241
  evictedHashes[key] = hash;
602
- evictedSeenAt[key] = nextSeenAt[key] ?? nowIso;
242
+ evictedSeenAt[key] = nextHashes[key] ?? nowIso;
603
243
  }
604
244
  const liveCachePath = path.join(livePath, "sync-cache.json");
605
245
  writeSyncCache(liveCachePath, {
@@ -611,14 +251,14 @@ export async function syncCommand(options = {}) {
611
251
  if (markdownFiles.length > 0 && entityCount < markdownFiles.length) {
612
252
  diagnostics.push(createDocsNotIndexedDiagnostic(markdownFiles.length, entityCount));
613
253
  }
614
- console.log(`✓ Imported ${entityCount} entities, ${relCount} relationships`);
254
+ console.log(`✓ Imported ${entityCount} entities, ${relationshipCount} relationships`);
615
255
  const commit = getCurrentCommit();
616
256
  const summary = {
617
257
  branch: currentBranch,
618
258
  commit,
619
259
  timestamp: new Date().toISOString(),
620
260
  entityCounts,
621
- relationshipCount: relCount,
261
+ relationshipCount,
622
262
  success: true,
623
263
  published,
624
264
  failures: diagnostics,
@@ -635,14 +275,13 @@ export async function syncCommand(options = {}) {
635
275
  catch (error) {
636
276
  const errorMessage = error instanceof Error ? error.message : String(error);
637
277
  console.error(`Error: ${errorMessage}`);
638
- // Return failure summary
639
278
  const commit = getCurrentCommit();
640
279
  const summary = {
641
280
  branch: currentBranch || "unknown",
642
281
  commit,
643
282
  timestamp: new Date().toISOString(),
644
283
  entityCounts,
645
- relationshipCount,
284
+ relationshipCount: 0,
646
285
  success: false,
647
286
  published: false,
648
287
  failures: diagnostics,
@@ -657,76 +296,5 @@ export async function syncCommand(options = {}) {
657
296
  throw error;
658
297
  }
659
298
  }
660
- async function refreshManifestCoordinates(manifestPath, workspaceRoot) {
661
- const rawContent = readFileSync(manifestPath, "utf8");
662
- const parsed = parseYAML(rawContent);
663
- if (!isRecord(parsed)) {
664
- console.warn(`Warning: symbols manifest ${manifestPath} is not a YAML object; skipping coordinate refresh`);
665
- return;
666
- }
667
- const rawSymbols = parsed.symbols;
668
- if (!Array.isArray(rawSymbols)) {
669
- console.warn(`Warning: symbols manifest ${manifestPath} has no symbols array; skipping coordinate refresh`);
670
- return;
671
- }
672
- const before = rawSymbols.map((entry) => isRecord(entry)
673
- ? { ...entry }
674
- : {});
675
- const enriched = await enrichSymbolCoordinates(before, workspaceRoot);
676
- parsed.symbols = enriched;
677
- let refreshed = 0;
678
- let failed = 0;
679
- let unchanged = 0;
680
- for (let i = 0; i < before.length; i++) {
681
- const previous = before[i] ?? {};
682
- const current = enriched[i] ?? previous;
683
- const changed = GENERATED_COORD_FIELDS.some((field) => previous[field] !== current[field]);
684
- if (changed) {
685
- refreshed++;
686
- continue;
687
- }
688
- const eligible = isEligibleForCoordinateRefresh(typeof current.sourceFile === "string"
689
- ? current.sourceFile
690
- : typeof previous.sourceFile === "string"
691
- ? previous.sourceFile
692
- : undefined, workspaceRoot);
693
- if (eligible && !hasAllGeneratedCoordinates(current)) {
694
- failed++;
695
- }
696
- else {
697
- unchanged++;
698
- }
699
- }
700
- const dumped = dumpYAML(parsed, {
701
- lineWidth: -1,
702
- noRefs: true,
703
- sortKeys: false,
704
- });
705
- const nextContent = `${SYMBOLS_MANIFEST_COMMENT_BLOCK}${dumped}`;
706
- if (rawContent !== nextContent) {
707
- writeFileSync(manifestPath, nextContent, "utf8");
708
- }
709
- console.log(`✓ Refreshed symbol coordinates in ${path.relative(workspaceRoot, manifestPath)} (refreshed=${refreshed}, unchanged=${unchanged}, failed=${failed})`);
710
- }
711
- function hasAllGeneratedCoordinates(entry) {
712
- return (typeof entry.sourceLine === "number" &&
713
- typeof entry.sourceColumn === "number" &&
714
- typeof entry.sourceEndLine === "number" &&
715
- typeof entry.sourceEndColumn === "number" &&
716
- typeof entry.coordinatesGeneratedAt === "string" &&
717
- entry.coordinatesGeneratedAt.length > 0);
718
- }
719
- function isEligibleForCoordinateRefresh(sourceFile, workspaceRoot) {
720
- if (!sourceFile)
721
- return false;
722
- const absolute = path.isAbsolute(sourceFile)
723
- ? sourceFile
724
- : path.resolve(workspaceRoot, sourceFile);
725
- if (!existsSync(absolute))
726
- return false;
727
- const ext = path.extname(absolute).toLowerCase();
728
- return SYMBOL_COORD_EXTENSIONS.has(ext);
729
- }
730
- function isRecord(value) {
731
- return typeof value === "object" && value !== null && !Array.isArray(value);
732
- }
299
+ // Export for use by modules that need these functions
300
+ export { normalizeMarkdownPath } from "./sync/discovery.js";