@specferret/core 0.1.4 → 0.3.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.
Files changed (103) hide show
  1. package/dist/config.d.ts +3 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js.map +1 -1
  4. package/dist/contract.d.ts +16 -0
  5. package/dist/contract.d.ts.map +1 -0
  6. package/dist/contract.js +17 -0
  7. package/dist/contract.js.map +1 -0
  8. package/dist/extractor/__fixtures__/cross-file-id.fixture.d.ts +15 -0
  9. package/dist/extractor/__fixtures__/cross-file-id.fixture.d.ts.map +1 -0
  10. package/dist/extractor/__fixtures__/cross-file-id.fixture.js +9 -0
  11. package/dist/extractor/__fixtures__/cross-file-id.fixture.js.map +1 -0
  12. package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.d.ts +14 -0
  13. package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.d.ts.map +1 -0
  14. package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.js +9 -0
  15. package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.js.map +1 -0
  16. package/dist/extractor/__fixtures__/empty.fixture.d.ts +6 -0
  17. package/dist/extractor/__fixtures__/empty.fixture.d.ts.map +1 -0
  18. package/dist/extractor/__fixtures__/empty.fixture.js +5 -0
  19. package/dist/extractor/__fixtures__/empty.fixture.js.map +1 -0
  20. package/dist/extractor/__fixtures__/external-no-id.fixture.d.ts +8 -0
  21. package/dist/extractor/__fixtures__/external-no-id.fixture.d.ts.map +1 -0
  22. package/dist/extractor/__fixtures__/external-no-id.fixture.js +7 -0
  23. package/dist/extractor/__fixtures__/external-no-id.fixture.js.map +1 -0
  24. package/dist/extractor/__fixtures__/external-with-id.fixture.d.ts +9 -0
  25. package/dist/extractor/__fixtures__/external-with-id.fixture.d.ts.map +1 -0
  26. package/dist/extractor/__fixtures__/external-with-id.fixture.js +8 -0
  27. package/dist/extractor/__fixtures__/external-with-id.fixture.js.map +1 -0
  28. package/dist/extractor/__fixtures__/mixed.fixture.d.ts +24 -0
  29. package/dist/extractor/__fixtures__/mixed.fixture.d.ts.map +1 -0
  30. package/dist/extractor/__fixtures__/mixed.fixture.js +15 -0
  31. package/dist/extractor/__fixtures__/mixed.fixture.js.map +1 -0
  32. package/dist/extractor/__fixtures__/one-contract.fixture.d.ts +10 -0
  33. package/dist/extractor/__fixtures__/one-contract.fixture.d.ts.map +1 -0
  34. package/dist/extractor/__fixtures__/one-contract.fixture.js +10 -0
  35. package/dist/extractor/__fixtures__/one-contract.fixture.js.map +1 -0
  36. package/dist/extractor/__fixtures__/optional-fields.fixture.d.ts +10 -0
  37. package/dist/extractor/__fixtures__/optional-fields.fixture.d.ts.map +1 -0
  38. package/dist/extractor/__fixtures__/optional-fields.fixture.js +10 -0
  39. package/dist/extractor/__fixtures__/optional-fields.fixture.js.map +1 -0
  40. package/dist/extractor/__fixtures__/same-file-consumes.fixture.d.ts +20 -0
  41. package/dist/extractor/__fixtures__/same-file-consumes.fixture.d.ts.map +1 -0
  42. package/dist/extractor/__fixtures__/same-file-consumes.fixture.js +11 -0
  43. package/dist/extractor/__fixtures__/same-file-consumes.fixture.js.map +1 -0
  44. package/dist/extractor/__fixtures__/three-unresolved.fixture.d.ts +38 -0
  45. package/dist/extractor/__fixtures__/three-unresolved.fixture.d.ts.map +1 -0
  46. package/dist/extractor/__fixtures__/three-unresolved.fixture.js +21 -0
  47. package/dist/extractor/__fixtures__/three-unresolved.fixture.js.map +1 -0
  48. package/dist/extractor/__fixtures__/throws-on-import.fixture.d.ts +2 -0
  49. package/dist/extractor/__fixtures__/throws-on-import.fixture.d.ts.map +1 -0
  50. package/dist/extractor/__fixtures__/throws-on-import.fixture.js +5 -0
  51. package/dist/extractor/__fixtures__/throws-on-import.fixture.js.map +1 -0
  52. package/dist/extractor/frontmatter.d.ts +5 -1
  53. package/dist/extractor/frontmatter.d.ts.map +1 -1
  54. package/dist/extractor/frontmatter.js +5 -0
  55. package/dist/extractor/frontmatter.js.map +1 -1
  56. package/dist/extractor/typescript-contract.d.ts +3 -0
  57. package/dist/extractor/typescript-contract.d.ts.map +1 -0
  58. package/dist/extractor/typescript-contract.js +67 -0
  59. package/dist/extractor/typescript-contract.js.map +1 -0
  60. package/dist/extractor/upward-classifier.d.ts +25 -0
  61. package/dist/extractor/upward-classifier.d.ts.map +1 -0
  62. package/dist/extractor/upward-classifier.js +48 -0
  63. package/dist/extractor/upward-classifier.js.map +1 -0
  64. package/dist/index.d.ts +4 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +4 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/status/index.d.ts +20 -0
  69. package/dist/status/index.d.ts.map +1 -0
  70. package/dist/status/index.js +46 -0
  71. package/dist/status/index.js.map +1 -0
  72. package/dist/store/sqlite.d.ts +1 -1
  73. package/dist/store/sqlite.d.ts.map +1 -1
  74. package/dist/store/sqlite.js +37 -32
  75. package/dist/store/sqlite.js.map +1 -1
  76. package/dist/store/types.d.ts +6 -2
  77. package/dist/store/types.d.ts.map +1 -1
  78. package/package.json +3 -2
  79. package/src/config.ts +3 -0
  80. package/src/contract.test.ts +99 -0
  81. package/src/contract.ts +38 -0
  82. package/src/extractor/__fixtures__/cross-file-id.fixture.ts +9 -0
  83. package/src/extractor/__fixtures__/cross-file-unresolved.fixture.ts +9 -0
  84. package/src/extractor/__fixtures__/empty.fixture.ts +4 -0
  85. package/src/extractor/__fixtures__/external-no-id.fixture.ts +7 -0
  86. package/src/extractor/__fixtures__/external-with-id.fixture.ts +8 -0
  87. package/src/extractor/__fixtures__/mixed.fixture.ts +17 -0
  88. package/src/extractor/__fixtures__/one-contract.fixture.ts +10 -0
  89. package/src/extractor/__fixtures__/optional-fields.fixture.ts +10 -0
  90. package/src/extractor/__fixtures__/same-file-consumes.fixture.ts +12 -0
  91. package/src/extractor/__fixtures__/three-unresolved.fixture.ts +24 -0
  92. package/src/extractor/__fixtures__/throws-on-import.fixture.ts +3 -0
  93. package/src/extractor/frontmatter.test.ts +74 -0
  94. package/src/extractor/frontmatter.ts +11 -1
  95. package/src/extractor/typescript-contract.test.ts +143 -0
  96. package/src/extractor/typescript-contract.ts +76 -0
  97. package/src/extractor/upward-classifier.test.ts +188 -0
  98. package/src/extractor/upward-classifier.ts +68 -0
  99. package/src/index.ts +4 -0
  100. package/src/status/index.ts +68 -0
  101. package/src/store/sqlite.test.ts +135 -79
  102. package/src/store/sqlite.ts +45 -92
  103. package/src/store/types.ts +7 -6
@@ -0,0 +1,68 @@
1
+ // Pure function. No I/O. No side effects. Ever.
2
+ // Upward drift classifier — detects when a TypeScript implementation diverges from
3
+ // its declared contract schema (code → spec direction).
4
+
5
+ import { compareSchemas } from './validator.js';
6
+
7
+ export type UpwardDriftClass = 'BREAKING' | 'NON_BREAKING' | 'NOOP';
8
+
9
+ export interface UpwardDriftResult {
10
+ contractId: string;
11
+ driftClass: UpwardDriftClass;
12
+ sourceFile: string;
13
+ sourceSymbol: string;
14
+ reason: string;
15
+ }
16
+
17
+ /**
18
+ * Classifies the drift between a declared contract schema and the live code-derived schema.
19
+ *
20
+ * Input:
21
+ * - declaredSchema: the schema declared in the .contract.md frontmatter (the spec)
22
+ * - codeSchema: the schema extracted from the TypeScript source at lint time
23
+ *
24
+ * Output:
25
+ * - BREAKING: code change breaks the declared contract (required field removed, type changed, etc.)
26
+ * - NON_BREAKING: code change is additive but not declared (optional field added, enum value added)
27
+ * - NOOP: code and declared schema are semantically identical (hash-stable, no action needed)
28
+ *
29
+ * Uses the same classification taxonomy as compareSchemas for consistency.
30
+ * No-op formatting changes (property reorder with stable hash) return NOOP.
31
+ */
32
+ export function classifyUpwardDrift(
33
+ contractId: string,
34
+ declaredSchema: unknown,
35
+ codeSchema: unknown,
36
+ sourceFile: string,
37
+ sourceSymbol: string,
38
+ ): UpwardDriftResult {
39
+ const comparison = compareSchemas(declaredSchema, codeSchema);
40
+
41
+ if (comparison.classification === 'no-change') {
42
+ return {
43
+ contractId,
44
+ driftClass: 'NOOP',
45
+ sourceFile,
46
+ sourceSymbol,
47
+ reason: comparison.reason,
48
+ };
49
+ }
50
+
51
+ if (comparison.classification === 'breaking') {
52
+ return {
53
+ contractId,
54
+ driftClass: 'BREAKING',
55
+ sourceFile,
56
+ sourceSymbol,
57
+ reason: comparison.reason,
58
+ };
59
+ }
60
+
61
+ return {
62
+ contractId,
63
+ driftClass: 'NON_BREAKING',
64
+ sourceFile,
65
+ sourceSymbol,
66
+ reason: comparison.reason,
67
+ };
68
+ }
package/src/index.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  // Do not export llm-fallback — dynamic import only, never a default export.
3
3
 
4
4
  export * from './extractor/frontmatter.js';
5
+ export * from './extractor/typescript-contract.js';
5
6
  export * from './extractor/typescript.js';
6
7
  export * from './extractor/validator.js';
7
8
  export * from './extractor/contract-types.js';
8
9
  export * from './extractor/hash.js';
10
+ export * from './extractor/upward-classifier.js';
9
11
  export * from './context/index.js';
10
12
  export * from './store/types.js';
11
13
  export * from './store/sqlite.js';
@@ -14,3 +16,5 @@ export * from './reconciler/index.js';
14
16
  export * from './reconciler/import-suggestions.js';
15
17
  export * from './config.js';
16
18
  export * from './utils/paths.js';
19
+ export * from './contract.js';
20
+ export * from './status/index.js';
@@ -0,0 +1,68 @@
1
+ import type { DBStore, ContractStatus } from '../store/types.js';
2
+
3
+ export type StatusContractEntry = {
4
+ id: string;
5
+ status: ContractStatus;
6
+ driftClass: 'breaking' | 'stable' | 'roadmap';
7
+ dependentCount: number;
8
+ dependents: string[];
9
+ };
10
+
11
+ export type StatusReport = {
12
+ version: '2.0';
13
+ timestamp: string;
14
+ total: number;
15
+ stable: number;
16
+ roadmap: number;
17
+ needsReview: number;
18
+ contracts: StatusContractEntry[];
19
+ };
20
+
21
+ export async function buildStatusReport(store: DBStore): Promise<StatusReport> {
22
+ const [contracts, nodes, deps] = await Promise.all([store.getContracts(), store.getNodes(), store.getDependencies()]);
23
+
24
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
25
+ const dependentsByContractId = new Map<string, string[]>();
26
+
27
+ for (const dep of deps) {
28
+ const node = nodeById.get(dep.source_node_id);
29
+ if (!node) continue;
30
+ const list = dependentsByContractId.get(dep.target_contract_id) ?? [];
31
+ if (!list.includes(node.file_path)) list.push(node.file_path);
32
+ dependentsByContractId.set(dep.target_contract_id, list);
33
+ }
34
+
35
+ const entries: StatusContractEntry[] = contracts.map((c) => {
36
+ const dependents = dependentsByContractId.get(c.id) ?? [];
37
+ return {
38
+ id: c.id,
39
+ status: c.status,
40
+ driftClass: c.status === 'needs-review' ? 'breaking' : c.status === 'roadmap' ? 'roadmap' : 'stable',
41
+ dependentCount: dependents.length,
42
+ dependents,
43
+ };
44
+ });
45
+
46
+ return {
47
+ version: '2.0',
48
+ timestamp: new Date().toISOString(),
49
+ total: contracts.length,
50
+ stable: contracts.filter((c) => c.status === 'stable').length,
51
+ roadmap: contracts.filter((c) => c.status === 'roadmap').length,
52
+ needsReview: contracts.filter((c) => c.status === 'needs-review').length,
53
+ contracts: entries,
54
+ };
55
+ }
56
+
57
+ export function buildStatusMarkdown(report: StatusReport): string {
58
+ const lines = [
59
+ '# Contract Status',
60
+ '',
61
+ `Generated: ${report.timestamp}`,
62
+ '',
63
+ '| Contract | Status | Drift Class | Dependents |',
64
+ '| --- | --- | --- | --- |',
65
+ ...report.contracts.map((c) => `| ${c.id} | ${c.status} | ${c.driftClass} | ${c.dependentCount} |`),
66
+ ];
67
+ return lines.join('\n') + '\n';
68
+ }
@@ -1,45 +1,42 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "bun:test";
3
- import { SqliteStore } from "./sqlite.js";
4
- import type { FerretNode, FerretContract } from "./types.js";
5
- import { randomUUID } from "node:crypto";
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'bun:test';
3
+ import { SqliteStore } from './sqlite.js';
4
+ import type { FerretNode, FerretContract } from './types.js';
5
+ import { randomUUID } from 'node:crypto';
6
6
 
7
7
  // Use in-memory SQLite for all tests — fast, isolated, no disk cleanup needed
8
8
  function makeStore() {
9
- return new SqliteStore(":memory:");
9
+ return new SqliteStore(':memory:');
10
10
  }
11
11
 
12
12
  function makeNode(overrides: Partial<FerretNode> = {}): FerretNode {
13
13
  return {
14
14
  id: randomUUID(),
15
- file_path: "contracts/test.contract.md",
16
- hash: "abc123",
17
- status: "stable",
15
+ file_path: 'contracts/test.contract.md',
16
+ hash: 'abc123',
17
+ status: 'stable',
18
18
  ...overrides,
19
19
  };
20
20
  }
21
21
 
22
- function makeContract(
23
- nodeId: string,
24
- overrides: Partial<FerretContract> = {},
25
- ): FerretContract {
22
+ function makeContract(nodeId: string, overrides: Partial<FerretContract> = {}): FerretContract {
26
23
  return {
27
24
  id: `api.GET/test-${randomUUID()}`,
28
25
  node_id: nodeId,
29
- shape_hash: "sha256hashvalue",
26
+ shape_hash: 'sha256hashvalue',
30
27
  shape_schema: JSON.stringify({
31
- type: "object",
32
- properties: { id: { type: "string" } },
33
- required: ["id"],
28
+ type: 'object',
29
+ properties: { id: { type: 'string' } },
30
+ required: ['id'],
34
31
  }),
35
- type: "api",
36
- status: "stable",
32
+ type: 'api',
33
+ status: 'stable',
37
34
  ...overrides,
38
35
  };
39
36
  }
40
37
 
41
- describe("SqliteStore — Task 1: shape_schema field", () => {
42
- it("upserts a contract with shape_schema field", async () => {
38
+ describe('SqliteStore — Task 1: shape_schema field', () => {
39
+ it('upserts a contract with shape_schema field', async () => {
43
40
  const store = makeStore();
44
41
  await store.init();
45
42
 
@@ -48,9 +45,9 @@ describe("SqliteStore — Task 1: shape_schema field", () => {
48
45
 
49
46
  const contract = makeContract(node.id, {
50
47
  shape_schema: JSON.stringify({
51
- type: "object",
52
- properties: { name: { type: "string" } },
53
- required: ["name"],
48
+ type: 'object',
49
+ properties: { name: { type: 'string' } },
50
+ required: ['name'],
54
51
  }),
55
52
  });
56
53
  await store.upsertContract(contract);
@@ -62,7 +59,7 @@ describe("SqliteStore — Task 1: shape_schema field", () => {
62
59
  await store.close();
63
60
  });
64
61
 
65
- it("retrieves a contract with shape_schema field intact", async () => {
62
+ it('retrieves a contract with shape_schema field intact', async () => {
66
63
  const store = makeStore();
67
64
  await store.init();
68
65
 
@@ -70,9 +67,9 @@ describe("SqliteStore — Task 1: shape_schema field", () => {
70
67
  await store.upsertNode(node);
71
68
 
72
69
  const schema = {
73
- type: "object",
74
- properties: { email: { type: "string", format: "email" } },
75
- required: ["email"],
70
+ type: 'object',
71
+ properties: { email: { type: 'string', format: 'email' } },
72
+ required: ['email'],
76
73
  };
77
74
  const contract = makeContract(node.id, {
78
75
  shape_schema: JSON.stringify(schema),
@@ -87,7 +84,7 @@ describe("SqliteStore — Task 1: shape_schema field", () => {
87
84
  await store.close();
88
85
  });
89
86
 
90
- it("upserts (updates) a contract — shape_schema is overwritten correctly", async () => {
87
+ it('upserts (updates) a contract — shape_schema is overwritten correctly', async () => {
91
88
  const store = makeStore();
92
89
  await store.init();
93
90
 
@@ -95,13 +92,13 @@ describe("SqliteStore — Task 1: shape_schema field", () => {
95
92
  await store.upsertNode(node);
96
93
 
97
94
  const contract = makeContract(node.id, {
98
- shape_schema: JSON.stringify({ type: "string" }),
95
+ shape_schema: JSON.stringify({ type: 'string' }),
99
96
  });
100
97
  await store.upsertContract(contract);
101
98
 
102
99
  const updatedSchema = JSON.stringify({
103
- type: "object",
104
- properties: { id: { type: "string" } },
100
+ type: 'object',
101
+ properties: { id: { type: 'string' } },
105
102
  });
106
103
  await store.upsertContract({ ...contract, shape_schema: updatedSchema });
107
104
 
@@ -111,7 +108,7 @@ describe("SqliteStore — Task 1: shape_schema field", () => {
111
108
  await store.close();
112
109
  });
113
110
 
114
- it("migration: ALTER TABLE runs cleanly on an existing database that lacks shape_schema", async () => {
111
+ it('migration: ALTER TABLE runs cleanly on an existing database that lacks shape_schema', async () => {
115
112
  // Simulate a pre-migration DB by manually creating the table without shape_schema
116
113
  // then calling init() which should run the migration without throwing
117
114
  const store = makeStore();
@@ -121,7 +118,7 @@ describe("SqliteStore — Task 1: shape_schema field", () => {
121
118
  await store.close();
122
119
  });
123
120
 
124
- it("shape_schema defaults to empty JSON object string when not set explicitly", async () => {
121
+ it('shape_schema defaults to empty JSON object string when not set explicitly', async () => {
125
122
  const store = makeStore();
126
123
  await store.init();
127
124
 
@@ -129,27 +126,25 @@ describe("SqliteStore — Task 1: shape_schema field", () => {
129
126
  await store.upsertNode(node);
130
127
 
131
128
  // shape_schema has DEFAULT '{}' in the schema; pass it explicitly to match interface
132
- const contract = makeContract(node.id, { shape_schema: "{}" });
129
+ const contract = makeContract(node.id, { shape_schema: '{}' });
133
130
  await store.upsertContract(contract);
134
131
 
135
132
  const retrieved = await store.getContract(contract.id);
136
- assert.equal(retrieved!.shape_schema, "{}");
133
+ assert.equal(retrieved!.shape_schema, '{}');
137
134
 
138
135
  await store.close();
139
136
  });
140
137
  });
141
138
 
142
- describe("SqliteStore — existing store functionality still passes", () => {
143
- it("upserts and retrieves a node by file path", async () => {
139
+ describe('SqliteStore — existing store functionality still passes', () => {
140
+ it('upserts and retrieves a node by file path', async () => {
144
141
  const store = makeStore();
145
142
  await store.init();
146
143
 
147
- const node = makeNode({ file_path: "contracts/auth.contract.md" });
144
+ const node = makeNode({ file_path: 'contracts/auth.contract.md' });
148
145
  await store.upsertNode(node);
149
146
 
150
- const retrieved = await store.getNodeByFilePath(
151
- "contracts/auth.contract.md",
152
- );
147
+ const retrieved = await store.getNodeByFilePath('contracts/auth.contract.md');
153
148
  assert.notEqual(retrieved, null);
154
149
  assert.equal(retrieved!.id, node.id);
155
150
  assert.equal(retrieved!.hash, node.hash);
@@ -157,43 +152,41 @@ describe("SqliteStore — existing store functionality still passes", () => {
157
152
  await store.close();
158
153
  });
159
154
 
160
- it("returns null for unknown file path", async () => {
155
+ it('returns null for unknown file path', async () => {
161
156
  const store = makeStore();
162
157
  await store.init();
163
- const result = await store.getNodeByFilePath(
164
- "contracts/nonexistent.contract.md",
165
- );
158
+ const result = await store.getNodeByFilePath('contracts/nonexistent.contract.md');
166
159
  assert.equal(result, null);
167
160
  await store.close();
168
161
  });
169
162
 
170
- it("getAllContractIds returns all contract IDs", async () => {
163
+ it('getAllContractIds returns all contract IDs', async () => {
171
164
  const store = makeStore();
172
165
  await store.init();
173
166
 
174
167
  const node = makeNode();
175
168
  await store.upsertNode(node);
176
- const c1 = makeContract(node.id, { id: "api.GET/one" });
177
- const c2 = makeContract(node.id, { id: "api.GET/two" });
169
+ const c1 = makeContract(node.id, { id: 'api.GET/one' });
170
+ const c2 = makeContract(node.id, { id: 'api.GET/two' });
178
171
  await store.upsertContract(c1);
179
172
  await store.upsertContract(c2);
180
173
 
181
174
  const ids = await store.getAllContractIds();
182
- assert.ok(ids.includes("api.GET/one"));
183
- assert.ok(ids.includes("api.GET/two"));
175
+ assert.ok(ids.includes('api.GET/one'));
176
+ assert.ok(ids.includes('api.GET/two'));
184
177
 
185
178
  await store.close();
186
179
  });
187
180
 
188
- it("updateNodeStatus changes node status", async () => {
181
+ it('updateNodeStatus changes node status', async () => {
189
182
  const store = makeStore();
190
183
  await store.init();
191
184
 
192
- const node = makeNode({ status: "stable" });
185
+ const node = makeNode({ status: 'stable' });
193
186
  await store.upsertNode(node);
194
- await store.updateNodeStatus(node.id, "needs-review");
187
+ await store.updateNodeStatus(node.id, 'needs-review');
195
188
 
196
- const nodes = await store.getNodesByStatus("needs-review");
189
+ const nodes = await store.getNodesByStatus('needs-review');
197
190
  assert.equal(
198
191
  nodes.some((n) => n.id === node.id),
199
192
  true,
@@ -202,22 +195,22 @@ describe("SqliteStore — existing store functionality still passes", () => {
202
195
  await store.close();
203
196
  });
204
197
 
205
- it("upsertDependency and getDependencies work correctly", async () => {
198
+ it('upsertDependency and getDependencies work correctly', async () => {
206
199
  const store = makeStore();
207
200
  await store.init();
208
201
 
209
202
  const nodeA = makeNode({
210
- id: "node-a",
211
- file_path: "contracts/a.contract.md",
203
+ id: 'node-a',
204
+ file_path: 'contracts/a.contract.md',
212
205
  });
213
206
  const nodeB = makeNode({
214
- id: "node-b",
215
- file_path: "contracts/b.contract.md",
207
+ id: 'node-b',
208
+ file_path: 'contracts/b.contract.md',
216
209
  });
217
210
  await store.upsertNode(nodeA);
218
211
  await store.upsertNode(nodeB);
219
212
 
220
- const contract = makeContract(nodeA.id, { id: "api.GET/shared" });
213
+ const contract = makeContract(nodeA.id, { id: 'api.GET/shared' });
221
214
  await store.upsertContract(contract);
222
215
 
223
216
  await store.upsertDependency({
@@ -228,45 +221,108 @@ describe("SqliteStore — existing store functionality still passes", () => {
228
221
 
229
222
  const deps = await store.getDependencies();
230
223
  assert.equal(
231
- deps.some(
232
- (d) =>
233
- d.source_node_id === nodeB.id && d.target_contract_id === contract.id,
234
- ),
224
+ deps.some((d) => d.source_node_id === nodeB.id && d.target_contract_id === contract.id),
235
225
  true,
236
226
  );
237
227
 
238
228
  await store.close();
239
229
  });
240
230
 
241
- it("replaceDependenciesForSourceNode replaces stale edges and deduplicates targets", async () => {
231
+ it('replaceDependenciesForSourceNode replaces stale edges and deduplicates targets', async () => {
242
232
  const store = makeStore();
243
233
  await store.init();
244
234
 
245
235
  const nodeA = makeNode({
246
- id: "node-a",
247
- file_path: "contracts/a.contract.md",
236
+ id: 'node-a',
237
+ file_path: 'contracts/a.contract.md',
248
238
  });
249
239
  await store.upsertNode(nodeA);
250
240
 
251
- await store.replaceDependenciesForSourceNode(nodeA.id, [
252
- "api.GET/one",
253
- "api.GET/one",
254
- "api.GET/two",
255
- ]);
241
+ await store.replaceDependenciesForSourceNode(nodeA.id, ['api.GET/one', 'api.GET/one', 'api.GET/two']);
256
242
 
257
243
  let deps = await store.getDependencies();
258
244
  assert.equal(deps.length, 2);
259
- assert.deepEqual(
260
- deps.map((dependency) => dependency.target_contract_id).sort(),
261
- ["api.GET/one", "api.GET/two"],
262
- );
245
+ assert.deepEqual(deps.map((dependency) => dependency.target_contract_id).sort(), ['api.GET/one', 'api.GET/two']);
263
246
 
264
- await store.replaceDependenciesForSourceNode(nodeA.id, ["api.GET/two"]);
247
+ await store.replaceDependenciesForSourceNode(nodeA.id, ['api.GET/two']);
265
248
 
266
249
  deps = await store.getDependencies();
267
250
  assert.equal(deps.length, 1);
268
- assert.equal(deps[0].target_contract_id, "api.GET/two");
251
+ assert.equal(deps[0].target_contract_id, 'api.GET/two');
252
+
253
+ await store.close();
254
+ });
255
+ });
256
+
257
+ describe('SqliteStore — S50: code_source_file and code_source_symbol', () => {
258
+ it('persists code_source_file and code_source_symbol when provided', async () => {
259
+ const store = makeStore();
260
+ await store.init();
261
+
262
+ const node = makeNode();
263
+ await store.upsertNode(node);
264
+
265
+ const contract = makeContract(node.id, {
266
+ code_source_file: 'src/auth/jwt.ts',
267
+ code_source_symbol: 'JwtPayload',
268
+ });
269
+ await store.upsertContract(contract);
270
+
271
+ const retrieved = await store.getContract(contract.id);
272
+ assert.equal(retrieved!.code_source_file, 'src/auth/jwt.ts');
273
+ assert.equal(retrieved!.code_source_symbol, 'JwtPayload');
274
+
275
+ await store.close();
276
+ });
277
+
278
+ it('stores null when code_source_file and code_source_symbol are absent', async () => {
279
+ const store = makeStore();
280
+ await store.init();
281
+
282
+ const node = makeNode();
283
+ await store.upsertNode(node);
269
284
 
285
+ const contract = makeContract(node.id);
286
+ await store.upsertContract(contract);
287
+
288
+ const retrieved = await store.getContract(contract.id);
289
+ // SQLite returns null for missing TEXT columns
290
+ assert.ok(retrieved!.code_source_file == null);
291
+ assert.ok(retrieved!.code_source_symbol == null);
292
+
293
+ await store.close();
294
+ });
295
+
296
+ it('overwrites code_source_file on upsert', async () => {
297
+ const store = makeStore();
298
+ await store.init();
299
+
300
+ const node = makeNode();
301
+ await store.upsertNode(node);
302
+
303
+ const contract = makeContract(node.id, {
304
+ code_source_file: 'src/old.ts',
305
+ code_source_symbol: 'OldSymbol',
306
+ });
307
+ await store.upsertContract(contract);
308
+
309
+ await store.upsertContract({
310
+ ...contract,
311
+ code_source_file: 'src/new.ts',
312
+ code_source_symbol: 'NewSymbol',
313
+ });
314
+
315
+ const retrieved = await store.getContract(contract.id);
316
+ assert.equal(retrieved!.code_source_file, 'src/new.ts');
317
+ assert.equal(retrieved!.code_source_symbol, 'NewSymbol');
318
+
319
+ await store.close();
320
+ });
321
+
322
+ it('migration for code_source columns is idempotent (double init does not throw)', async () => {
323
+ const store = makeStore();
324
+ await store.init();
325
+ await store.init();
270
326
  await store.close();
271
327
  });
272
328
  });