opencode-telos 1.0.0 → 1.0.2

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 (46) hide show
  1. package/dist/code-intelligence/ast/cache.d.ts.map +1 -1
  2. package/dist/code-intelligence/ast/cache.js +8 -5
  3. package/dist/code-intelligence/ast/cache.js.map +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/opencode/hooks.d.ts +1 -1
  8. package/dist/opencode/hooks.d.ts.map +1 -1
  9. package/dist/opencode/hooks.js +34 -40
  10. package/dist/opencode/hooks.js.map +1 -1
  11. package/dist/opencode/tools.d.ts.map +1 -1
  12. package/dist/opencode/tools.js +9 -8
  13. package/dist/opencode/tools.js.map +1 -1
  14. package/dist/sdd/cache/atomic.d.ts +3 -0
  15. package/dist/sdd/cache/atomic.d.ts.map +1 -0
  16. package/dist/sdd/cache/atomic.js +35 -0
  17. package/dist/sdd/cache/atomic.js.map +1 -0
  18. package/dist/sdd/cache/fingerprint.d.ts +18 -0
  19. package/dist/sdd/cache/fingerprint.d.ts.map +1 -0
  20. package/dist/sdd/cache/fingerprint.js +99 -0
  21. package/dist/sdd/cache/fingerprint.js.map +1 -0
  22. package/dist/sdd/cache/manager.d.ts +24 -10
  23. package/dist/sdd/cache/manager.d.ts.map +1 -1
  24. package/dist/sdd/cache/manager.js +184 -33
  25. package/dist/sdd/cache/manager.js.map +1 -1
  26. package/dist/sdd/cache/snapshot-store.d.ts +2 -1
  27. package/dist/sdd/cache/snapshot-store.d.ts.map +1 -1
  28. package/dist/sdd/cache/snapshot-store.js +13 -6
  29. package/dist/sdd/cache/snapshot-store.js.map +1 -1
  30. package/dist/sdd/persistence/sqlite.d.ts.map +1 -1
  31. package/dist/sdd/persistence/sqlite.js +66 -41
  32. package/dist/sdd/persistence/sqlite.js.map +1 -1
  33. package/dist/sdd/persistence/yaml.d.ts.map +1 -1
  34. package/dist/sdd/persistence/yaml.js +33 -25
  35. package/dist/sdd/persistence/yaml.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/code-intelligence/ast/cache.ts +11 -6
  38. package/src/index.ts +4 -1
  39. package/src/opencode/hooks.ts +34 -39
  40. package/src/opencode/tools.ts +9 -8
  41. package/src/sdd/cache/atomic.ts +30 -0
  42. package/src/sdd/cache/fingerprint.ts +98 -0
  43. package/src/sdd/cache/manager.ts +182 -39
  44. package/src/sdd/cache/snapshot-store.ts +14 -7
  45. package/src/sdd/persistence/sqlite.ts +50 -24
  46. package/src/sdd/persistence/yaml.ts +40 -26
@@ -171,18 +171,18 @@ export function detectShellFileWrites(command: string): string[] {
171
171
  return [...new Set(files)]
172
172
  }
173
173
 
174
- export function createSddHooks(): Hooks {
174
+ export function createSddHooks(projectDir: string): Hooks {
175
175
  let systemInjected = false
176
176
 
177
177
  return {
178
178
  "experimental.chat.system.transform": async (_input, output) => {
179
179
  if (systemInjected) return
180
- if (!isSddEnabled(process.cwd())) return
180
+ if (!isSddEnabled(projectDir)) return
181
181
 
182
182
  // Run pending migrations on first load
183
- if (hasPendingMigrations(process.cwd())) {
183
+ if (hasPendingMigrations(projectDir)) {
184
184
  try {
185
- const migrationResults = runMigrations(process.cwd())
185
+ const migrationResults = runMigrations(projectDir)
186
186
  const successful = migrationResults.filter(r => r.success)
187
187
  if (successful.length > 0) {
188
188
  output.system.push(`## 🔄 opencode-telos Migrations: ${successful.length} fix(es) applied`)
@@ -193,10 +193,10 @@ export function createSddHooks(): Hooks {
193
193
  }
194
194
  }
195
195
 
196
- const repo = createRepository(process.cwd())
196
+ const repo = createRepository(projectDir)
197
197
  if (repo.isInitialized()) {
198
198
  // Restore persistent cache from disk (cross-session)
199
- const cacheMgr = getCacheManager(process.cwd())
199
+ const cacheMgr = getCacheManager(projectDir)
200
200
  cacheMgr.restoreFromPersistentCache()
201
201
 
202
202
  output.system.push(SDD_CORE_SYSTEM_PROMPT)
@@ -204,7 +204,7 @@ export function createSddHooks(): Hooks {
204
204
  // Tool Registry: injeta tools relevantes para o estado atual do grafo
205
205
  try {
206
206
  const { getGraphSnapshot } = await import("./router/graph-state-snapshot.js")
207
- const snapshot = getGraphSnapshot(process.cwd())
207
+ const snapshot = getGraphSnapshot(projectDir)
208
208
  const { formatGraphState } = await import("./router/graph-state-snapshot.js")
209
209
  output.system.push(formatGraphState(snapshot))
210
210
  } catch {}
@@ -233,17 +233,12 @@ export function createSddHooks(): Hooks {
233
233
  output.system.push(`## Pending Changes: ${pending.length} change(s) awaiting action`)
234
234
  }
235
235
 
236
- // G: Save graph snapshot to disk for fast cross-session restore
237
- try {
238
- const cacheMgrForSnapshot = getCacheManager(process.cwd())
239
- cacheMgrForSnapshot.saveGraphSnapshot(graph)
240
- } catch {}
241
236
  } catch {
242
237
  // Handoff is optional, don't fail if it can't be generated
243
238
  }
244
239
  // Persist cache to disk for cross-session reuse
245
240
  try {
246
- const cacheMgr = getCacheManager(process.cwd())
241
+ const cacheMgr = getCacheManager(projectDir)
247
242
  cacheMgr.persistToDisk()
248
243
  } catch {}
249
244
  systemInjected = true
@@ -260,14 +255,14 @@ export function createSddHooks(): Hooks {
260
255
 
261
256
  // Detect /sdd commands
262
257
  if (text === "/sdd on") {
263
- const state = setToggleState(process.cwd(), true)
258
+ const state = setToggleState(projectDir, true)
264
259
  systemInjected = false
265
260
  part.text = `✅ SDD enforcement **enabled** at ${state.changed_at}.\n\nSpec-Driven Development is now active. All code changes will go through the SDD workflow.`
266
261
  return
267
262
  }
268
263
 
269
264
  if (text === "/sdd off") {
270
- const state = setToggleState(process.cwd(), false)
265
+ const state = setToggleState(projectDir, false)
271
266
  systemInjected = false
272
267
  resetWorkflowState()
273
268
  part.text = `⏸️ SDD enforcement **disabled** at ${state.changed_at}.\n\nYou can now make code changes freely without SDD workflow. Use \`/sdd on\` to re-enable.`
@@ -275,7 +270,7 @@ export function createSddHooks(): Hooks {
275
270
  }
276
271
 
277
272
  if (text === "/sdd status") {
278
- const state = getToggleState(process.cwd())
273
+ const state = getToggleState(projectDir)
279
274
  const status = state.enabled ? "🟢 ON" : "🔴 OFF"
280
275
  part.text = `SDD Status: ${status}\nLast changed: ${state.changed_at}\n\nCommands: \`/sdd on\`, \`/sdd off\`, \`/sdd status\`, \`/sdd cache reset\``
281
276
  return
@@ -283,7 +278,7 @@ export function createSddHooks(): Hooks {
283
278
 
284
279
  // H: /sdd cache reset — full cache reset without killing the process
285
280
  if (text === "/sdd cache reset") {
286
- const cacheMgr = getCacheManager(process.cwd())
281
+ const cacheMgr = getCacheManager(projectDir)
287
282
  const result = cacheMgr.fullReset()
288
283
  const lines = ["## 🧹 Cache Reset Complete"]
289
284
  lines.push(`- Memory cache: ${result.cleared.memory ? "✅ cleared" : "⏭️ skipped"}`)
@@ -297,7 +292,7 @@ export function createSddHooks(): Hooks {
297
292
  }
298
293
 
299
294
  // Semantic nudge — replaces regex-based pattern detection
300
- if (isSddEnabled(process.cwd())) {
295
+ if (isSddEnabled(projectDir)) {
301
296
  const nudge = formatNudgeInput(text)
302
297
  if (nudge) {
303
298
  part.text += `\n\n${nudge}`
@@ -308,14 +303,14 @@ export function createSddHooks(): Hooks {
308
303
 
309
304
  "tool.execute.before": async (input, output) => {
310
305
  // Skip enforcement if SDD is disabled
311
- if (!isSddEnabled(process.cwd())) return
306
+ if (!isSddEnabled(projectDir)) return
312
307
 
313
308
  // Enforce workflow context for SDD graph mutation tools
314
309
  if (input.tool.startsWith("sdd.")) {
315
310
  const access = checkToolAccess(input.tool)
316
311
  if (!access.allowed) {
317
312
  addAuditEntry(
318
- process.cwd(),
313
+ projectDir,
319
314
  process.env.USER || "current",
320
315
  input.tool,
321
316
  "graph",
@@ -333,7 +328,7 @@ export function createSddHooks(): Hooks {
333
328
  // ENFORCEMENT: Block shell writes to .sdd/ directory
334
329
  if (command.includes(".sdd/") && (/[>]|writeFile|open\(['"].*['"],\s*['"]w/.test(command))) {
335
330
  addAuditEntry(
336
- process.cwd(),
331
+ projectDir,
337
332
  process.env.USER || "current",
338
333
  "shell_command",
339
334
  ".sdd/",
@@ -353,7 +348,7 @@ export function createSddHooks(): Hooks {
353
348
 
354
349
  const detectedFiles = detectShellFileWrites(command)
355
350
  if (detectedFiles.length > 0) {
356
- const repo = createRepository(process.cwd())
351
+ const repo = createRepository(projectDir)
357
352
  if (!repo.isInitialized()) return
358
353
 
359
354
  const graph = repo.loadGraph()
@@ -364,7 +359,7 @@ export function createSddHooks(): Hooks {
364
359
 
365
360
  if (!hasSpecNodes && graph.nodes.length > 0) {
366
361
  addAuditEntry(
367
- process.cwd(),
362
+ projectDir,
368
363
  process.env.USER || "current",
369
364
  "shell_command",
370
365
  detectedFiles.join(", "),
@@ -399,7 +394,7 @@ export function createSddHooks(): Hooks {
399
394
 
400
395
  if (approvedChanges.length === 0) {
401
396
  addAuditEntry(
402
- process.cwd(),
397
+ projectDir,
403
398
  process.env.USER || "current",
404
399
  "shell_command",
405
400
  detectedFiles.join(", "),
@@ -429,7 +424,7 @@ export function createSddHooks(): Hooks {
429
424
  const shellWorkflow = getWorkflowState()
430
425
  if (shellWorkflow.enforced && !shellWorkflow.specUpdated) {
431
426
  addAuditEntry(
432
- process.cwd(),
427
+ projectDir,
433
428
  process.env.USER || "current",
434
429
  "shell_command",
435
430
  detectedFiles.join(", "),
@@ -456,10 +451,10 @@ export function createSddHooks(): Hooks {
456
451
 
457
452
  // Approved — create snapshots
458
453
  for (const change of approvedChanges) {
459
- createSnapshot(graph, change.id, process.cwd())
454
+ createSnapshot(graph, change.id, projectDir)
460
455
  }
461
456
  addAuditEntry(
462
- process.cwd(),
457
+ projectDir,
463
458
  process.env.USER || "current",
464
459
  "shell_command",
465
460
  detectedFiles.join(", "),
@@ -479,7 +474,7 @@ export function createSddHooks(): Hooks {
479
474
  // The SDD graph can ONLY be modified through sdd.* tools
480
475
  if (filePath.includes(".sdd/")) {
481
476
  addAuditEntry(
482
- process.cwd(),
477
+ projectDir,
483
478
  process.env.USER || "current",
484
479
  input.tool,
485
480
  filePath,
@@ -508,7 +503,7 @@ export function createSddHooks(): Hooks {
508
503
  if (!isSourceFile) return
509
504
 
510
505
  // Check if SDD is initialized
511
- const repo = createRepository(process.cwd())
506
+ const repo = createRepository(projectDir)
512
507
  if (!repo.isInitialized()) return
513
508
 
514
509
  const graph = repo.loadGraph()
@@ -519,7 +514,7 @@ export function createSddHooks(): Hooks {
519
514
 
520
515
  if (!hasSpecNodes && graph.nodes.length > 0) {
521
516
  addAuditEntry(
522
- process.cwd(),
517
+ projectDir,
523
518
  process.env.USER || "current",
524
519
  "write_file",
525
520
  filePath,
@@ -543,12 +538,12 @@ export function createSddHooks(): Hooks {
543
538
  }
544
539
 
545
540
  // Check permissions before allowing changes
546
- const userRole = getUserRoleWithAuth(process.cwd(), process.env.USER || "current")
547
- const hasPermission = checkPermission(userRole, "create_change", process.cwd())
541
+ const userRole = getUserRoleWithAuth(projectDir, process.env.USER || "current")
542
+ const hasPermission = checkPermission(userRole, "create_change", projectDir)
548
543
 
549
544
  if (!hasPermission) {
550
545
  addAuditEntry(
551
- process.cwd(),
546
+ projectDir,
552
547
  process.env.USER || "current",
553
548
  "write_file",
554
549
  filePath,
@@ -577,7 +572,7 @@ export function createSddHooks(): Hooks {
577
572
  const workflow = getWorkflowState()
578
573
  if (workflow.enforced && !workflow.specUpdated) {
579
574
  addAuditEntry(
580
- process.cwd(),
575
+ projectDir,
581
576
  process.env.USER || "current",
582
577
  "write_file",
583
578
  filePath,
@@ -604,10 +599,10 @@ export function createSddHooks(): Hooks {
604
599
 
605
600
  // Create snapshot before approving change
606
601
  for (const change of approvedChanges) {
607
- createSnapshot(graph, change.id, process.cwd())
602
+ createSnapshot(graph, change.id, projectDir)
608
603
  }
609
604
  addAuditEntry(
610
- process.cwd(),
605
+ projectDir,
611
606
  process.env.USER || "current",
612
607
  "write_file",
613
608
  filePath,
@@ -619,7 +614,7 @@ export function createSddHooks(): Hooks {
619
614
 
620
615
  // File not covered by any approved Change → BLOCK the write
621
616
  addAuditEntry(
622
- process.cwd(),
617
+ projectDir,
623
618
  process.env.USER || "current",
624
619
  "write_file",
625
620
  filePath,
@@ -689,7 +684,7 @@ export function createSddHooks(): Hooks {
689
684
  },
690
685
 
691
686
  "tool.definition": async (input, output) => {
692
- if (!isSddEnabled(process.cwd())) return
687
+ if (!isSddEnabled(projectDir)) return
693
688
 
694
689
  // Inject SDD enforcement warning into run_terminal_command description
695
690
  if (input.toolID === "run_terminal_command") {
@@ -719,7 +714,7 @@ export function createSddHooks(): Hooks {
719
714
  dispose: async () => {
720
715
  // E: Persist cache and release resources on session end
721
716
  try {
722
- const cacheMgr = getCacheManager(process.cwd())
717
+ const cacheMgr = getCacheManager(projectDir)
723
718
  cacheMgr.persistToDisk()
724
719
  cacheMgr.releaseWriteLock()
725
720
  } catch {}
@@ -71,6 +71,7 @@ import {
71
71
  createDriftWhitelistTool,
72
72
  } from "./router/tools-composite.js"
73
73
  import { createWorkflowTools } from "./workflows/tools-workflow.js"
74
+ import { graphFingerprint } from "../sdd/cache/fingerprint.js"
74
75
 
75
76
  // ── Validation Coverage Index (singleton per session) ──────────────
76
77
  const validationIndex = new ValidationIndex()
@@ -95,7 +96,7 @@ function getCachedToolResponse(directory: string, toolName: string, args: Record
95
96
  const repo = getRepo(directory)
96
97
  if (!repo.isInitialized()) return null
97
98
  const graph = repo.loadGraph()
98
- return cacheMgr.getToolResponse(toolName, args, graph.metadata.updated_at)
99
+ return cacheMgr.getToolResponse(toolName, args, graphFingerprint(graph))
99
100
  } catch {
100
101
  return null
101
102
  }
@@ -110,7 +111,7 @@ function setCachedToolResponse(directory: string, toolName: string, args: Record
110
111
  const repo = getRepo(directory)
111
112
  if (!repo.isInitialized()) return
112
113
  const graph = repo.loadGraph()
113
- cacheMgr.setToolResponse(toolName, args, response, graph.metadata.updated_at)
114
+ cacheMgr.setToolResponse(toolName, args, response, graphFingerprint(graph))
114
115
  } catch {}
115
116
  }
116
117
 
@@ -698,7 +699,7 @@ export function createSddTools(): Record<string, ToolDefinition> {
698
699
 
699
700
  // Check analysis cache
700
701
  const cacheMgr = getCacheManager(ctx.directory)
701
- const cached = cacheMgr.getAnalysisResult("validate", graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
702
+ const cached = cacheMgr.getAnalysisResult("validate", graphFingerprint(graph))
702
703
  if (cached) {
703
704
  recordTelemetry(ctx.directory, {
704
705
  name: "graph_validation",
@@ -727,7 +728,7 @@ export function createSddTools(): Record<string, ToolDefinition> {
727
728
  }
728
729
 
729
730
  // Cache the result
730
- cacheMgr.setAnalysisResult("validate", formatted, graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
731
+ cacheMgr.setAnalysisResult("validate", formatted, graphFingerprint(graph))
731
732
  recordTelemetry(ctx.directory, {
732
733
  name: "graph_validation",
733
734
  duration_ms: Date.now() - startedAt,
@@ -751,14 +752,14 @@ export function createSddTools(): Record<string, ToolDefinition> {
751
752
 
752
753
  // Check analysis cache
753
754
  const cacheMgr = getCacheManager(ctx.directory)
754
- const cached = cacheMgr.getAnalysisResult("drift", graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
755
+ const cached = cacheMgr.getAnalysisResult("drift", graphFingerprint(graph))
755
756
  if (cached) return cached as string
756
757
 
757
758
  const result = detectDrift(graph, ctx.directory)
758
759
  const formatted = formatDriftReport(result)
759
760
 
760
761
  // Cache the result
761
- cacheMgr.setAnalysisResult("drift", formatted, graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
762
+ cacheMgr.setAnalysisResult("drift", formatted, graphFingerprint(graph))
762
763
 
763
764
  return formatted
764
765
  },
@@ -1867,14 +1868,14 @@ export function createSddTools(): Record<string, ToolDefinition> {
1867
1868
 
1868
1869
  // Check analysis cache
1869
1870
  const cacheMgr = getCacheManager(ctx.directory)
1870
- const cached = cacheMgr.getAnalysisResult("quality", graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
1871
+ const cached = cacheMgr.getAnalysisResult("quality", graphFingerprint(graph))
1871
1872
  if (cached) return cached as string
1872
1873
 
1873
1874
  const report = calculateQualityScore(graph, ctx.directory)
1874
1875
  const formatted = formatQualityReport(report)
1875
1876
 
1876
1877
  // Cache the result
1877
- cacheMgr.setAnalysisResult("quality", formatted, graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
1878
+ cacheMgr.setAnalysisResult("quality", formatted, graphFingerprint(graph))
1878
1879
 
1879
1880
  return formatted
1880
1881
  },
@@ -0,0 +1,30 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, writeFileSync } from "fs"
2
+ import { dirname } from "path"
3
+
4
+ /** Write a cache artifact so readers see either the old complete file or the new complete file. */
5
+ export function atomicWriteFile(filePath: string, content: string): void {
6
+ const directory = dirname(filePath)
7
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true })
8
+
9
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
10
+ let descriptor: number | undefined
11
+ try {
12
+ descriptor = openSync(temporaryPath, "w", 0o600)
13
+ writeFileSync(descriptor, content, "utf-8")
14
+ fsyncSync(descriptor)
15
+ closeSync(descriptor)
16
+ descriptor = undefined
17
+ renameSync(temporaryPath, filePath)
18
+ } finally {
19
+ if (descriptor !== undefined) {
20
+ try { closeSync(descriptor) } catch {}
21
+ }
22
+ try {
23
+ // A failed rename must not leave a misleading cache artifact behind.
24
+ if (existsSync(temporaryPath)) {
25
+ const { unlinkSync } = require("fs")
26
+ unlinkSync(temporaryPath)
27
+ }
28
+ } catch {}
29
+ }
30
+ }
@@ -0,0 +1,98 @@
1
+ import { createHash } from "crypto"
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs"
3
+ import { join, relative } from "path"
4
+ import type { KnowledgeGraph } from "../domain/types.js"
5
+
6
+ const SOURCE_EXTENSIONS = new Set([
7
+ ".c", ".cc", ".cpp", ".cs", ".go", ".h", ".hpp", ".java", ".js", ".jsx",
8
+ ".json", ".kt", ".mjs", ".mts", ".py", ".rb", ".rs", ".svelte", ".swift",
9
+ ".ts", ".tsx", ".vue", ".yaml", ".yml",
10
+ ])
11
+
12
+ const IGNORED_DIRECTORIES = new Set([
13
+ ".git", ".sdd", ".opencode", "node_modules", "dist", "build", "coverage",
14
+ ".cache", ".next", ".turbo", "target", "vendor",
15
+ ])
16
+
17
+ export function stableSerialize(value: unknown): string {
18
+ if (value === null) return "null"
19
+ if (value === undefined) return "undefined"
20
+ if (typeof value !== "object") return JSON.stringify(value)
21
+ if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`
22
+
23
+ const record = value as Record<string, unknown>
24
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(",")}}`
25
+ }
26
+
27
+ export function sha256(value: string | Uint8Array): string {
28
+ return createHash("sha256").update(value).digest("hex")
29
+ }
30
+
31
+ /** Deterministic content fingerprint for the complete graph, independent of YAML/SQLite formatting. */
32
+ export function graphFingerprint(graph: KnowledgeGraph): string {
33
+ return sha256(stableSerialize({
34
+ version: graph.version,
35
+ project_id: graph.project_id,
36
+ metadata: graph.metadata,
37
+ nodes: [...graph.nodes].sort((a, b) => a.id.localeCompare(b.id)),
38
+ relationships: [...graph.relationships].sort((a, b) => a.id.localeCompare(b.id)),
39
+ }))
40
+ }
41
+
42
+ export function fileContentFingerprint(filePath: string): string {
43
+ try {
44
+ return sha256(readFileSync(filePath))
45
+ } catch {
46
+ return sha256(`missing:${filePath}`)
47
+ }
48
+ }
49
+
50
+ /** Fast identity for external-file detection; includes all files when several files are supplied. */
51
+ export function fileSignature(filePaths: string[]): string {
52
+ const parts = filePaths.map((filePath) => {
53
+ try {
54
+ const stat = statSync(filePath)
55
+ return `${filePath}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`
56
+ } catch {
57
+ return `${filePath}:missing`
58
+ }
59
+ })
60
+ return sha256(parts.sort().join("\n"))
61
+ }
62
+
63
+ export function configFingerprint(projectDir: string): string {
64
+ const path = join(projectDir, ".sdd", "config.json")
65
+ return existsSync(path) ? fileContentFingerprint(path) : sha256("missing-config")
66
+ }
67
+
68
+ function sourceFiles(projectDir: string): string[] {
69
+ const files: string[] = []
70
+ const visit = (directory: string): void => {
71
+ let entries
72
+ try { entries = readdirSync(directory, { withFileTypes: true }) } catch { return }
73
+ for (const entry of entries) {
74
+ if (entry.isSymbolicLink()) continue
75
+ const path = join(directory, entry.name)
76
+ if (entry.isDirectory()) {
77
+ if (!IGNORED_DIRECTORIES.has(entry.name)) visit(path)
78
+ continue
79
+ }
80
+ const extension = entry.name.slice(entry.name.lastIndexOf(".")).toLowerCase()
81
+ if (SOURCE_EXTENSIONS.has(extension)) files.push(path)
82
+ }
83
+ }
84
+ visit(projectDir)
85
+ return files.sort()
86
+ }
87
+
88
+ /** Content fingerprint for code-dependent analyses. The caller can cache this by fileSignature. */
89
+ export function sourceFingerprint(
90
+ projectDir: string,
91
+ previous?: { fingerprint: string; signature: string } | null,
92
+ ): { fingerprint: string; signature: string } {
93
+ const files = sourceFiles(projectDir)
94
+ const signature = fileSignature(files)
95
+ if (previous?.signature === signature) return previous
96
+ const content = files.map((path) => `${relative(projectDir, path)}:${fileContentFingerprint(path)}`).join("\n")
97
+ return { fingerprint: sha256(content), signature }
98
+ }