@specferret/core 0.1.4 → 0.2.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/extractor/frontmatter.d.ts +4 -0
- package/dist/extractor/frontmatter.d.ts.map +1 -1
- package/dist/extractor/frontmatter.js +5 -0
- package/dist/extractor/frontmatter.js.map +1 -1
- package/dist/extractor/upward-classifier.d.ts +25 -0
- package/dist/extractor/upward-classifier.d.ts.map +1 -0
- package/dist/extractor/upward-classifier.js +48 -0
- package/dist/extractor/upward-classifier.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +37 -32
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/types.d.ts +6 -2
- package/dist/store/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/extractor/frontmatter.test.ts +74 -0
- package/src/extractor/frontmatter.ts +10 -0
- package/src/extractor/upward-classifier.test.ts +188 -0
- package/src/extractor/upward-classifier.ts +68 -0
- package/src/index.ts +1 -0
- package/src/store/sqlite.test.ts +135 -79
- package/src/store/sqlite.ts +45 -92
- package/src/store/types.ts +7 -6
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'bun:test';
|
|
3
|
+
import { classifyUpwardDrift } from './upward-classifier.js';
|
|
4
|
+
|
|
5
|
+
const FILE = 'src/auth/jwt.ts';
|
|
6
|
+
const SYMBOL = 'JwtPayload';
|
|
7
|
+
const CONTRACT_ID = 'auth.jwt';
|
|
8
|
+
|
|
9
|
+
// ─── Canonical declared schema ────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const DECLARED_SCHEMA = {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
sub: { type: 'string' },
|
|
15
|
+
role: { type: 'string', enum: ['admin', 'user'] },
|
|
16
|
+
exp: { type: 'number' },
|
|
17
|
+
},
|
|
18
|
+
required: ['sub', 'role'],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('classifyUpwardDrift — S51: BREAKING cases', () => {
|
|
22
|
+
it('required field removed from code shape → BREAKING', () => {
|
|
23
|
+
const codeSchema = {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
role: { type: 'string', enum: ['admin', 'user'] },
|
|
27
|
+
exp: { type: 'number' },
|
|
28
|
+
},
|
|
29
|
+
required: ['role'],
|
|
30
|
+
};
|
|
31
|
+
const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
|
|
32
|
+
assert.equal(result.driftClass, 'BREAKING');
|
|
33
|
+
assert.match(result.reason, /required field\(s\) removed: sub/);
|
|
34
|
+
assert.equal(result.contractId, CONTRACT_ID);
|
|
35
|
+
assert.equal(result.sourceFile, FILE);
|
|
36
|
+
assert.equal(result.sourceSymbol, SYMBOL);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('field type changed in code → BREAKING', () => {
|
|
40
|
+
const codeSchema = {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
sub: { type: 'number' }, // was string
|
|
44
|
+
role: { type: 'string', enum: ['admin', 'user'] },
|
|
45
|
+
exp: { type: 'number' },
|
|
46
|
+
},
|
|
47
|
+
required: ['sub', 'role'],
|
|
48
|
+
};
|
|
49
|
+
const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
|
|
50
|
+
assert.equal(result.driftClass, 'BREAKING');
|
|
51
|
+
assert.match(result.reason, /type changed/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('required field added in code → BREAKING', () => {
|
|
55
|
+
const codeSchema = {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
sub: { type: 'string' },
|
|
59
|
+
role: { type: 'string', enum: ['admin', 'user'] },
|
|
60
|
+
exp: { type: 'number' },
|
|
61
|
+
iat: { type: 'number' },
|
|
62
|
+
},
|
|
63
|
+
required: ['sub', 'role', 'iat'], // iat newly required
|
|
64
|
+
};
|
|
65
|
+
const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
|
|
66
|
+
assert.equal(result.driftClass, 'BREAKING');
|
|
67
|
+
assert.match(result.reason, /required field\(s\) added: iat/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('property removed entirely from code → BREAKING', () => {
|
|
71
|
+
const codeSchema = {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
sub: { type: 'string' },
|
|
75
|
+
exp: { type: 'number' },
|
|
76
|
+
// role removed entirely
|
|
77
|
+
},
|
|
78
|
+
required: ['sub'],
|
|
79
|
+
};
|
|
80
|
+
const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
|
|
81
|
+
assert.equal(result.driftClass, 'BREAKING');
|
|
82
|
+
assert.match(result.reason, /property 'role' removed|required field\(s\) removed/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('enum value removed from code → BREAKING', () => {
|
|
86
|
+
const codeSchema = {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
sub: { type: 'string' },
|
|
90
|
+
role: { type: 'string', enum: ['admin'] }, // 'user' removed
|
|
91
|
+
exp: { type: 'number' },
|
|
92
|
+
},
|
|
93
|
+
required: ['sub', 'role'],
|
|
94
|
+
};
|
|
95
|
+
const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
|
|
96
|
+
assert.equal(result.driftClass, 'BREAKING');
|
|
97
|
+
assert.match(result.reason, /enum value\(s\) removed/);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('classifyUpwardDrift — S51: NON_BREAKING cases', () => {
|
|
102
|
+
it('optional field added in code → NON_BREAKING', () => {
|
|
103
|
+
const codeSchema = {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
sub: { type: 'string' },
|
|
107
|
+
role: { type: 'string', enum: ['admin', 'user'] },
|
|
108
|
+
exp: { type: 'number' },
|
|
109
|
+
nbf: { type: 'number' }, // new optional field
|
|
110
|
+
},
|
|
111
|
+
required: ['sub', 'role'],
|
|
112
|
+
};
|
|
113
|
+
const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
|
|
114
|
+
assert.equal(result.driftClass, 'NON_BREAKING');
|
|
115
|
+
assert.match(result.reason, /optional field\(s\) added/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('enum value added to code → NON_BREAKING', () => {
|
|
119
|
+
const codeSchema = {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
sub: { type: 'string' },
|
|
123
|
+
role: { type: 'string', enum: ['admin', 'user', 'superuser'] }, // 'superuser' added
|
|
124
|
+
exp: { type: 'number' },
|
|
125
|
+
},
|
|
126
|
+
required: ['sub', 'role'],
|
|
127
|
+
};
|
|
128
|
+
const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
|
|
129
|
+
assert.equal(result.driftClass, 'NON_BREAKING');
|
|
130
|
+
assert.match(result.reason, /enum value\(s\) added/);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('classifyUpwardDrift — S51: NOOP cases', () => {
|
|
135
|
+
it('identical schemas → NOOP', () => {
|
|
136
|
+
const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, DECLARED_SCHEMA, FILE, SYMBOL);
|
|
137
|
+
assert.equal(result.driftClass, 'NOOP');
|
|
138
|
+
assert.match(result.reason, /semantically identical/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('property order change in code does not produce drift (hash-stable)', () => {
|
|
142
|
+
const codeSchema = {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
// same properties, different key order
|
|
146
|
+
exp: { type: 'number' },
|
|
147
|
+
role: { type: 'string', enum: ['admin', 'user'] },
|
|
148
|
+
sub: { type: 'string' },
|
|
149
|
+
},
|
|
150
|
+
required: ['role', 'sub'], // reordered required array
|
|
151
|
+
};
|
|
152
|
+
const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, codeSchema, FILE, SYMBOL);
|
|
153
|
+
assert.equal(result.driftClass, 'NOOP');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('empty vs empty schemas → NOOP', () => {
|
|
157
|
+
const result = classifyUpwardDrift(CONTRACT_ID, {}, {}, FILE, SYMBOL);
|
|
158
|
+
assert.equal(result.driftClass, 'NOOP');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('classifyUpwardDrift — S51: result shape', () => {
|
|
163
|
+
it('result always contains all required fields', () => {
|
|
164
|
+
const result = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, DECLARED_SCHEMA, FILE, SYMBOL);
|
|
165
|
+
assert.ok('contractId' in result);
|
|
166
|
+
assert.ok('driftClass' in result);
|
|
167
|
+
assert.ok('sourceFile' in result);
|
|
168
|
+
assert.ok('sourceSymbol' in result);
|
|
169
|
+
assert.ok('reason' in result);
|
|
170
|
+
assert.equal(typeof result.reason, 'string');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('is a pure function — same inputs produce same output', () => {
|
|
174
|
+
const r1 = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, DECLARED_SCHEMA, FILE, SYMBOL);
|
|
175
|
+
const r2 = classifyUpwardDrift(CONTRACT_ID, DECLARED_SCHEMA, DECLARED_SCHEMA, FILE, SYMBOL);
|
|
176
|
+
assert.deepEqual(r1, r2);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does not mutate inputs', () => {
|
|
180
|
+
const declared = { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] };
|
|
181
|
+
const code = { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] };
|
|
182
|
+
const declaredBefore = JSON.stringify(declared);
|
|
183
|
+
const codeBefore = JSON.stringify(code);
|
|
184
|
+
classifyUpwardDrift(CONTRACT_ID, declared, code, FILE, SYMBOL);
|
|
185
|
+
assert.equal(JSON.stringify(declared), declaredBefore);
|
|
186
|
+
assert.equal(JSON.stringify(code), codeBefore);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -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
|
@@ -6,6 +6,7 @@ export * from './extractor/typescript.js';
|
|
|
6
6
|
export * from './extractor/validator.js';
|
|
7
7
|
export * from './extractor/contract-types.js';
|
|
8
8
|
export * from './extractor/hash.js';
|
|
9
|
+
export * from './extractor/upward-classifier.js';
|
|
9
10
|
export * from './context/index.js';
|
|
10
11
|
export * from './store/types.js';
|
|
11
12
|
export * from './store/sqlite.js';
|
package/src/store/sqlite.test.ts
CHANGED
|
@@ -1,45 +1,42 @@
|
|
|
1
|
-
import assert from
|
|
2
|
-
import { describe, it } from
|
|
3
|
-
import { SqliteStore } from
|
|
4
|
-
import type { FerretNode, FerretContract } from
|
|
5
|
-
import { randomUUID } from
|
|
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(
|
|
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:
|
|
16
|
-
hash:
|
|
17
|
-
status:
|
|
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:
|
|
26
|
+
shape_hash: 'sha256hashvalue',
|
|
30
27
|
shape_schema: JSON.stringify({
|
|
31
|
-
type:
|
|
32
|
-
properties: { id: { type:
|
|
33
|
-
required: [
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: { id: { type: 'string' } },
|
|
30
|
+
required: ['id'],
|
|
34
31
|
}),
|
|
35
|
-
type:
|
|
36
|
-
status:
|
|
32
|
+
type: 'api',
|
|
33
|
+
status: 'stable',
|
|
37
34
|
...overrides,
|
|
38
35
|
};
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
describe(
|
|
42
|
-
it(
|
|
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:
|
|
52
|
-
properties: { name: { type:
|
|
53
|
-
required: [
|
|
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(
|
|
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:
|
|
74
|
-
properties: { email: { type:
|
|
75
|
-
required: [
|
|
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(
|
|
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:
|
|
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:
|
|
104
|
-
properties: { id: { type:
|
|
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(
|
|
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(
|
|
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(
|
|
143
|
-
it(
|
|
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:
|
|
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(
|
|
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(
|
|
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:
|
|
177
|
-
const c2 = makeContract(node.id, { id:
|
|
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(
|
|
183
|
-
assert.ok(ids.includes(
|
|
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(
|
|
181
|
+
it('updateNodeStatus changes node status', async () => {
|
|
189
182
|
const store = makeStore();
|
|
190
183
|
await store.init();
|
|
191
184
|
|
|
192
|
-
const node = makeNode({ status:
|
|
185
|
+
const node = makeNode({ status: 'stable' });
|
|
193
186
|
await store.upsertNode(node);
|
|
194
|
-
await store.updateNodeStatus(node.id,
|
|
187
|
+
await store.updateNodeStatus(node.id, 'needs-review');
|
|
195
188
|
|
|
196
|
-
const nodes = await store.getNodesByStatus(
|
|
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(
|
|
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:
|
|
211
|
-
file_path:
|
|
203
|
+
id: 'node-a',
|
|
204
|
+
file_path: 'contracts/a.contract.md',
|
|
212
205
|
});
|
|
213
206
|
const nodeB = makeNode({
|
|
214
|
-
id:
|
|
215
|
-
file_path:
|
|
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:
|
|
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(
|
|
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:
|
|
247
|
-
file_path:
|
|
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, [
|
|
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,
|
|
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
|
});
|