@specferret/core 0.4.3 → 0.5.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/README.md +1 -1
- package/dist/audit/index.d.ts +1 -1
- package/dist/audit/index.js +1 -1
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +24 -2
- package/dist/context/index.js.map +1 -1
- package/dist/contract.d.ts +4 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/extractor/__fixtures__/active-status.fixture.d.ts +10 -0
- package/dist/extractor/__fixtures__/active-status.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/active-status.fixture.js +10 -0
- package/dist/extractor/__fixtures__/active-status.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/no-status.fixture.d.ts +9 -0
- package/dist/extractor/__fixtures__/no-status.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/no-status.fixture.js +9 -0
- package/dist/extractor/__fixtures__/no-status.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/source-field.fixture.d.ts +9 -0
- package/dist/extractor/__fixtures__/source-field.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/source-field.fixture.js +11 -0
- package/dist/extractor/__fixtures__/source-field.fixture.js.map +1 -0
- package/dist/extractor/frontmatter.d.ts +7 -0
- package/dist/extractor/frontmatter.d.ts.map +1 -1
- package/dist/extractor/frontmatter.js +10 -0
- package/dist/extractor/frontmatter.js.map +1 -1
- package/dist/extractor/typescript-contract.d.ts.map +1 -1
- package/dist/extractor/typescript-contract.js +4 -2
- package/dist/extractor/typescript-contract.js.map +1 -1
- package/dist/reconciler/index.js +5 -5
- package/dist/reconciler/index.js.map +1 -1
- package/dist/status/index.d.ts +2 -2
- package/dist/status/index.js +2 -2
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +11 -2
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/types.d.ts +2 -2
- package/package.json +1 -1
- package/src/audit/index.test.ts +129 -0
- package/src/audit/index.ts +2 -2
- package/src/context/index.test.ts +34 -5
- package/src/context/index.ts +27 -2
- package/src/contract.ts +3 -6
- package/src/extractor/__fixtures__/active-status.fixture.ts +10 -0
- package/src/extractor/__fixtures__/no-status.fixture.ts +9 -0
- package/src/extractor/__fixtures__/source-field.fixture.ts +11 -0
- package/src/extractor/frontmatter.test.ts +71 -0
- package/src/extractor/frontmatter.ts +14 -0
- package/src/extractor/typescript-contract.test.ts +20 -0
- package/src/extractor/typescript-contract.ts +4 -2
- package/src/reconciler/index.ts +5 -5
- package/src/status/index.ts +4 -4
- package/src/store/sqlite.test.ts +45 -0
- package/src/store/sqlite.ts +10 -2
- package/src/store/types.ts +2 -2
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { describe, it } from 'bun:test';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { buildAuditReport } from './index.js';
|
|
8
|
+
import { SqliteStore } from '../store/sqlite.js';
|
|
9
|
+
|
|
10
|
+
function makeTmpDir(): string {
|
|
11
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-audit-test-'));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('buildAuditReport — S63 source field upward drift', () => {
|
|
15
|
+
it('matching src type → no upward drift', async () => {
|
|
16
|
+
const tmpDir = makeTmpDir();
|
|
17
|
+
try {
|
|
18
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
19
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
20
|
+
fs.writeFileSync(path.join(srcDir, 'handler.ts'), `export interface HandlerResponse {\n name: string;\n}\n`, 'utf-8');
|
|
21
|
+
|
|
22
|
+
const store = new SqliteStore(':memory:');
|
|
23
|
+
await store.init();
|
|
24
|
+
|
|
25
|
+
const nodeId = randomUUID();
|
|
26
|
+
await store.upsertNode({
|
|
27
|
+
id: nodeId,
|
|
28
|
+
file_path: 'contracts/handler.contract.ts',
|
|
29
|
+
hash: randomUUID(),
|
|
30
|
+
status: 'stable',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await store.upsertContract({
|
|
34
|
+
id: 'api.handler',
|
|
35
|
+
node_id: nodeId,
|
|
36
|
+
shape_hash: 'abc',
|
|
37
|
+
shape_schema: JSON.stringify({
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: { name: { type: 'string' } },
|
|
40
|
+
required: ['name'],
|
|
41
|
+
}),
|
|
42
|
+
type: 'type',
|
|
43
|
+
status: 'stable',
|
|
44
|
+
code_source_file: 'src/handler.ts',
|
|
45
|
+
code_source_symbol: 'HandlerResponse',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const report = await buildAuditReport(store, tmpDir);
|
|
49
|
+
assert.equal(report.upwardDrift.length, 0, 'expected no upward drift when src matches declared schema');
|
|
50
|
+
|
|
51
|
+
await store.close();
|
|
52
|
+
} finally {
|
|
53
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('diverged src type (extra required field) → BREAKING upward drift', async () => {
|
|
58
|
+
const tmpDir = makeTmpDir();
|
|
59
|
+
try {
|
|
60
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
61
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
62
|
+
// src has an extra required field not in the declared schema
|
|
63
|
+
fs.writeFileSync(path.join(srcDir, 'handler.ts'), `export interface HandlerResponse {\n name: string;\n email: string;\n}\n`, 'utf-8');
|
|
64
|
+
|
|
65
|
+
const store = new SqliteStore(':memory:');
|
|
66
|
+
await store.init();
|
|
67
|
+
|
|
68
|
+
const nodeId = randomUUID();
|
|
69
|
+
await store.upsertNode({
|
|
70
|
+
id: nodeId,
|
|
71
|
+
file_path: 'contracts/handler.contract.ts',
|
|
72
|
+
hash: randomUUID(),
|
|
73
|
+
status: 'stable',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await store.upsertContract({
|
|
77
|
+
id: 'api.handler',
|
|
78
|
+
node_id: nodeId,
|
|
79
|
+
shape_hash: 'abc',
|
|
80
|
+
shape_schema: JSON.stringify({
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: { name: { type: 'string' } },
|
|
83
|
+
required: ['name'],
|
|
84
|
+
}),
|
|
85
|
+
type: 'type',
|
|
86
|
+
status: 'stable',
|
|
87
|
+
code_source_file: 'src/handler.ts',
|
|
88
|
+
code_source_symbol: 'HandlerResponse',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const report = await buildAuditReport(store, tmpDir);
|
|
92
|
+
assert.equal(report.upwardDrift.length, 1, 'expected 1 upward drift item');
|
|
93
|
+
assert.equal(report.upwardDrift[0].contractId, 'api.handler');
|
|
94
|
+
assert.equal(report.upwardDrift[0].driftClass, 'BREAKING');
|
|
95
|
+
|
|
96
|
+
await store.close();
|
|
97
|
+
} finally {
|
|
98
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('no source fields → no upward drift (behaviour identical to today)', async () => {
|
|
103
|
+
const store = new SqliteStore(':memory:');
|
|
104
|
+
await store.init();
|
|
105
|
+
|
|
106
|
+
const nodeId = randomUUID();
|
|
107
|
+
await store.upsertNode({
|
|
108
|
+
id: nodeId,
|
|
109
|
+
file_path: 'contracts/handler.contract.ts',
|
|
110
|
+
hash: randomUUID(),
|
|
111
|
+
status: 'stable',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await store.upsertContract({
|
|
115
|
+
id: 'api.handler',
|
|
116
|
+
node_id: nodeId,
|
|
117
|
+
shape_hash: 'abc',
|
|
118
|
+
shape_schema: JSON.stringify({ type: 'object' }),
|
|
119
|
+
type: 'type',
|
|
120
|
+
status: 'stable',
|
|
121
|
+
// no code_source_file or code_source_symbol
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const report = await buildAuditReport(store, process.cwd());
|
|
125
|
+
assert.equal(report.upwardDrift.length, 0, 'contracts without source fields must not produce upward drift');
|
|
126
|
+
|
|
127
|
+
await store.close();
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/audit/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface AuditSummary {
|
|
|
19
19
|
totalContracts: number;
|
|
20
20
|
stable: number;
|
|
21
21
|
needsReview: number;
|
|
22
|
-
|
|
22
|
+
pending: number;
|
|
23
23
|
downwardBreaking: number;
|
|
24
24
|
downwardNonBreaking: number;
|
|
25
25
|
upwardBreaking: number;
|
|
@@ -104,7 +104,7 @@ export async function buildAuditReport(store: DBStore, projectRoot: string): Pro
|
|
|
104
104
|
totalContracts: contracts.length,
|
|
105
105
|
stable: statusReport.stable,
|
|
106
106
|
needsReview: statusReport.needsReview,
|
|
107
|
-
|
|
107
|
+
pending: statusReport.pending,
|
|
108
108
|
downwardBreaking,
|
|
109
109
|
downwardNonBreaking,
|
|
110
110
|
upwardBreaking,
|
|
@@ -113,7 +113,7 @@ describe("writeContext — Task 5", () => {
|
|
|
113
113
|
await store.close();
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
it('contains version "
|
|
116
|
+
it('contains version "3.0"', async () => {
|
|
117
117
|
const tmpDir = makeTmpDir();
|
|
118
118
|
tmps.push(tmpDir);
|
|
119
119
|
const { store } = await makeStoreWithData(tmpDir);
|
|
@@ -123,7 +123,7 @@ describe("writeContext — Task 5", () => {
|
|
|
123
123
|
const ctx = JSON.parse(
|
|
124
124
|
fs.readFileSync(path.join(tmpDir, ".ferret", "context.json"), "utf-8"),
|
|
125
125
|
) as FerretContext;
|
|
126
|
-
assert.equal(ctx.version, "
|
|
126
|
+
assert.equal(ctx.version, "3.0");
|
|
127
127
|
|
|
128
128
|
await store.close();
|
|
129
129
|
});
|
|
@@ -143,7 +143,7 @@ describe("writeContext — Task 5", () => {
|
|
|
143
143
|
await store.close();
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
it("readContextFile migrates
|
|
146
|
+
it("readContextFile migrates v2.0 payload to v3.0 (no schemaVersion)", () => {
|
|
147
147
|
const tmpDir = makeTmpDir();
|
|
148
148
|
tmps.push(tmpDir);
|
|
149
149
|
const ferretDir = path.join(tmpDir, ".ferret");
|
|
@@ -166,7 +166,7 @@ describe("writeContext — Task 5", () => {
|
|
|
166
166
|
);
|
|
167
167
|
|
|
168
168
|
const context = readContextFile(contextPath);
|
|
169
|
-
assert.equal(context.version, "
|
|
169
|
+
assert.equal(context.version, "3.0");
|
|
170
170
|
assert.equal(context.schemaVersion, CONTEXT_SCHEMA_VERSION);
|
|
171
171
|
assert.deepEqual(context.contracts, []);
|
|
172
172
|
assert.deepEqual(context.edges, []);
|
|
@@ -207,7 +207,7 @@ describe("writeContext — Task 5", () => {
|
|
|
207
207
|
fs.writeFileSync(
|
|
208
208
|
contextPath,
|
|
209
209
|
JSON.stringify({
|
|
210
|
-
version: "
|
|
210
|
+
version: "3.0",
|
|
211
211
|
schemaVersion: "9.0.0",
|
|
212
212
|
generated: new Date().toISOString(),
|
|
213
213
|
contracts: [],
|
|
@@ -271,4 +271,33 @@ describe("writeContext — Task 5", () => {
|
|
|
271
271
|
|
|
272
272
|
await store.close();
|
|
273
273
|
});
|
|
274
|
+
|
|
275
|
+
it("readContextFile v2.0 migration: roadmap entries become pending", () => {
|
|
276
|
+
const tmpDir = makeTmpDir();
|
|
277
|
+
tmps.push(tmpDir);
|
|
278
|
+
const ferretDir = path.join(tmpDir, ".ferret");
|
|
279
|
+
fs.mkdirSync(ferretDir, { recursive: true });
|
|
280
|
+
const contextPath = path.join(ferretDir, "context.json");
|
|
281
|
+
fs.writeFileSync(
|
|
282
|
+
contextPath,
|
|
283
|
+
JSON.stringify({
|
|
284
|
+
version: "2.0",
|
|
285
|
+
generated: "2026-01-01T00:00:00.000Z",
|
|
286
|
+
contracts: [
|
|
287
|
+
{ id: "api.roadmap", type: "api", shape: {}, status: "roadmap", specFile: "contracts/api.contract.md", codeFile: null },
|
|
288
|
+
{ id: "api.stable", type: "api", shape: {}, status: "stable", specFile: "contracts/stable.contract.md", codeFile: null },
|
|
289
|
+
],
|
|
290
|
+
edges: [],
|
|
291
|
+
needsReview: [],
|
|
292
|
+
}),
|
|
293
|
+
"utf-8",
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const context = readContextFile(contextPath);
|
|
297
|
+
assert.equal(context.version, "3.0");
|
|
298
|
+
const roadmapContract = context.contracts.find((c) => c.id === "api.roadmap");
|
|
299
|
+
const stableContract = context.contracts.find((c) => c.id === "api.stable");
|
|
300
|
+
assert.equal(roadmapContract?.status, "pending");
|
|
301
|
+
assert.equal(stableContract?.status, "stable");
|
|
302
|
+
});
|
|
274
303
|
});
|
package/src/context/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { DBStore } from "../store/types.js";
|
|
4
4
|
|
|
5
|
-
export const CONTEXT_VERSION = "
|
|
5
|
+
export const CONTEXT_VERSION = "3.0" as const;
|
|
6
6
|
export const CONTEXT_SCHEMA_VERSION = "1.0.0" as const;
|
|
7
7
|
|
|
8
8
|
type LegacyFerretContextV2 = {
|
|
@@ -43,6 +43,31 @@ function normalizeContext(raw: unknown): FerretContext {
|
|
|
43
43
|
|
|
44
44
|
const candidate = raw as Record<string, unknown>;
|
|
45
45
|
const contextVersion = candidate.version;
|
|
46
|
+
|
|
47
|
+
// Migration: v2.0 → v3.0 (roadmap → pending)
|
|
48
|
+
if (contextVersion === "2.0") {
|
|
49
|
+
const legacy = candidate as Partial<LegacyFerretContextV2>;
|
|
50
|
+
const migratedContracts = (Array.isArray(legacy.contracts) ? legacy.contracts : [])
|
|
51
|
+
.filter((c: unknown): c is Record<string, unknown> => !!c && typeof c === 'object')
|
|
52
|
+
.map((entry) => ({
|
|
53
|
+
...entry,
|
|
54
|
+
status: entry['status'] === 'roadmap' ? 'pending' : entry['status'],
|
|
55
|
+
})) as ContextContract[];
|
|
56
|
+
return {
|
|
57
|
+
version: CONTEXT_VERSION,
|
|
58
|
+
schemaVersion: CONTEXT_SCHEMA_VERSION,
|
|
59
|
+
generated:
|
|
60
|
+
typeof legacy.generated === "string"
|
|
61
|
+
? legacy.generated
|
|
62
|
+
: new Date(0).toISOString(),
|
|
63
|
+
contracts: migratedContracts,
|
|
64
|
+
edges: Array.isArray(legacy.edges) ? legacy.edges : [],
|
|
65
|
+
needsReview: Array.isArray(legacy.needsReview)
|
|
66
|
+
? legacy.needsReview
|
|
67
|
+
: [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
if (contextVersion !== CONTEXT_VERSION) {
|
|
47
72
|
throw new Error(
|
|
48
73
|
`ferret: unsupported context.json version '${String(contextVersion)}'. Run 'ferret scan' with the current CLI to regenerate .ferret/context.json.`,
|
|
@@ -56,7 +81,7 @@ function normalizeContext(raw: unknown): FerretContext {
|
|
|
56
81
|
);
|
|
57
82
|
}
|
|
58
83
|
|
|
59
|
-
// Known migration path:
|
|
84
|
+
// Known migration path: V3 payloads created before schemaVersion was introduced.
|
|
60
85
|
const legacy = candidate as Partial<LegacyFerretContextV2>;
|
|
61
86
|
return {
|
|
62
87
|
version: CONTEXT_VERSION,
|
package/src/contract.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
-
export interface Contract<
|
|
4
|
-
T extends Record<string, z.ZodTypeAny> = Record<string, z.ZodTypeAny>,
|
|
5
|
-
> {
|
|
3
|
+
export interface Contract<T extends Record<string, z.ZodTypeAny> = Record<string, z.ZodTypeAny>> {
|
|
6
4
|
id?: string;
|
|
7
5
|
value: string;
|
|
8
6
|
output: T;
|
|
@@ -12,6 +10,7 @@ export interface Contract<
|
|
|
12
10
|
consumes?: ContractRef[];
|
|
13
11
|
forbids?: string[];
|
|
14
12
|
status?: 'complete' | 'active' | 'pending';
|
|
13
|
+
source?: { file: string; symbol: string };
|
|
15
14
|
closedBy?: string;
|
|
16
15
|
closedWhen?: string;
|
|
17
16
|
dependsOn?: ContractRef[];
|
|
@@ -24,9 +23,7 @@ export type ContractRef = Omit<Contract<any>, 'invariants' | 'schema'> & {
|
|
|
24
23
|
schema?: z.ZodObject<any>;
|
|
25
24
|
};
|
|
26
25
|
|
|
27
|
-
export function defineContract<T extends Record<string, z.ZodTypeAny>>(
|
|
28
|
-
contract: Contract<T>,
|
|
29
|
-
): Contract<T> & { schema: z.ZodObject<T> } {
|
|
26
|
+
export function defineContract<T extends Record<string, z.ZodTypeAny>>(contract: Contract<T>): Contract<T> & { schema: z.ZodObject<T> } {
|
|
30
27
|
if ('id' in contract && contract.id !== undefined && contract.id.trim() === '') {
|
|
31
28
|
throw new Error('defineContract: id must be a non-empty string if provided');
|
|
32
29
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineContract } from '../../contract.js';
|
|
3
|
+
|
|
4
|
+
export const apiGetKeywords = defineContract({
|
|
5
|
+
id: 'api.getKeywords',
|
|
6
|
+
value: 'Keywords endpoint',
|
|
7
|
+
output: {
|
|
8
|
+
keywords: z.array(z.string()),
|
|
9
|
+
},
|
|
10
|
+
source: { file: 'src/routes/keywords.ts', symbol: 'KeywordsResponse' },
|
|
11
|
+
});
|
|
@@ -289,3 +289,74 @@ ferret:
|
|
|
289
289
|
assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
|
|
290
290
|
});
|
|
291
291
|
});
|
|
292
|
+
|
|
293
|
+
describe('extractFromSpecFile — S62 contractStatus mapping', () => {
|
|
294
|
+
it('no status field → contractStatus is "pending"', () => {
|
|
295
|
+
const spec = `---
|
|
296
|
+
ferret:
|
|
297
|
+
id: api.no-status
|
|
298
|
+
type: api
|
|
299
|
+
shape:
|
|
300
|
+
type: object
|
|
301
|
+
---
|
|
302
|
+
`;
|
|
303
|
+
const result = extractFromSpecFile('contracts/no-status.contract.md', spec);
|
|
304
|
+
assert.equal(result.contracts[0].contractStatus, 'pending');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('status: active → contractStatus is "stable"', () => {
|
|
308
|
+
const spec = `---
|
|
309
|
+
ferret:
|
|
310
|
+
id: api.active
|
|
311
|
+
type: api
|
|
312
|
+
status: active
|
|
313
|
+
shape:
|
|
314
|
+
type: object
|
|
315
|
+
---
|
|
316
|
+
`;
|
|
317
|
+
const result = extractFromSpecFile('contracts/active.contract.md', spec);
|
|
318
|
+
assert.equal(result.contracts[0].contractStatus, 'stable');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('status: complete → contractStatus is "stable"', () => {
|
|
322
|
+
const spec = `---
|
|
323
|
+
ferret:
|
|
324
|
+
id: api.complete
|
|
325
|
+
type: api
|
|
326
|
+
status: complete
|
|
327
|
+
shape:
|
|
328
|
+
type: object
|
|
329
|
+
---
|
|
330
|
+
`;
|
|
331
|
+
const result = extractFromSpecFile('contracts/complete.contract.md', spec);
|
|
332
|
+
assert.equal(result.contracts[0].contractStatus, 'stable');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('status: pending → contractStatus is "pending"', () => {
|
|
336
|
+
const spec = `---
|
|
337
|
+
ferret:
|
|
338
|
+
id: api.pending
|
|
339
|
+
type: api
|
|
340
|
+
status: pending
|
|
341
|
+
shape:
|
|
342
|
+
type: object
|
|
343
|
+
---
|
|
344
|
+
`;
|
|
345
|
+
const result = extractFromSpecFile('contracts/pending.contract.md', spec);
|
|
346
|
+
assert.equal(result.contracts[0].contractStatus, 'pending');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('unknown status value → contractStatus defaults to "pending"', () => {
|
|
350
|
+
const spec = `---
|
|
351
|
+
ferret:
|
|
352
|
+
id: api.unknown
|
|
353
|
+
type: api
|
|
354
|
+
status: some-unknown-value
|
|
355
|
+
shape:
|
|
356
|
+
type: object
|
|
357
|
+
---
|
|
358
|
+
`;
|
|
359
|
+
const result = extractFromSpecFile('contracts/unknown.contract.md', spec);
|
|
360
|
+
assert.equal(result.contracts[0].contractStatus, 'pending');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
@@ -5,6 +5,15 @@ import matter from 'gray-matter';
|
|
|
5
5
|
import { validateContractType, validateFerretSchema } from './validator.js';
|
|
6
6
|
import { hashSchema } from './hash.js';
|
|
7
7
|
import type { ContractType } from './contract-types.js';
|
|
8
|
+
import type { ContractStatus } from '../store/types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Maps a raw `ferret.status` string value to a normalised ContractStatus.
|
|
12
|
+
* `active` and `complete` map to `'stable'`; anything else (including `undefined`) maps to `'pending'`.
|
|
13
|
+
*/
|
|
14
|
+
export function mapToContractStatus(rawStatus: unknown): ContractStatus {
|
|
15
|
+
return rawStatus === 'active' || rawStatus === 'complete' ? 'stable' : 'pending';
|
|
16
|
+
}
|
|
8
17
|
|
|
9
18
|
export interface ExtractionResult {
|
|
10
19
|
filePath: string;
|
|
@@ -15,6 +24,7 @@ export interface ExtractionResult {
|
|
|
15
24
|
shape: object;
|
|
16
25
|
shape_hash: string;
|
|
17
26
|
imports: string[];
|
|
27
|
+
contractStatus?: ContractStatus;
|
|
18
28
|
/** Path to the TypeScript source file (code-first contracts only). */
|
|
19
29
|
sourceFile?: string;
|
|
20
30
|
/** TypeScript symbol name (code-first contracts only). */
|
|
@@ -67,6 +77,9 @@ export function extractFromSpecFile(filePath: string, fileContent: string): Extr
|
|
|
67
77
|
const sourceFile = typeof source?.file === 'string' ? source.file : undefined;
|
|
68
78
|
const sourceSymbol = typeof source?.symbol === 'string' ? source.symbol : undefined;
|
|
69
79
|
|
|
80
|
+
const rawStatus = ferret.status as string | undefined;
|
|
81
|
+
const contractStatus: ContractStatus = mapToContractStatus(rawStatus);
|
|
82
|
+
|
|
70
83
|
return {
|
|
71
84
|
filePath,
|
|
72
85
|
fileType: 'spec',
|
|
@@ -77,6 +90,7 @@ export function extractFromSpecFile(filePath: string, fileContent: string): Extr
|
|
|
77
90
|
shape: ferret.shape as object,
|
|
78
91
|
shape_hash: hashSchema(ferret.shape),
|
|
79
92
|
imports: Array.isArray(ferret.imports) ? (ferret.imports as string[]) : [],
|
|
93
|
+
contractStatus,
|
|
80
94
|
...(sourceFile !== undefined && { sourceFile }),
|
|
81
95
|
...(sourceSymbol !== undefined && { sourceSymbol }),
|
|
82
96
|
},
|
|
@@ -153,4 +153,24 @@ describe('extractFromContractFile', () => {
|
|
|
153
153
|
it('module that throws at top-level causes extractFromContractFile to reject', async () => {
|
|
154
154
|
await assert.rejects(() => extractFromContractFile(fixtures('throws-on-import.fixture.ts')), /intentional module-level throw/);
|
|
155
155
|
});
|
|
156
|
+
|
|
157
|
+
it('S62: contract with status: active → contractStatus is "stable"', async () => {
|
|
158
|
+
const result = await extractFromContractFile(fixtures('active-status.fixture.ts'));
|
|
159
|
+
assert.equal(result.contracts.length, 1);
|
|
160
|
+
assert.equal(result.contracts[0].contractStatus, 'stable');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('S62: contract with no status field → contractStatus is "pending"', async () => {
|
|
164
|
+
const result = await extractFromContractFile(fixtures('no-status.fixture.ts'));
|
|
165
|
+
assert.equal(result.contracts.length, 1);
|
|
166
|
+
assert.equal(result.contracts[0].contractStatus, 'pending');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('S63: source field set → sourceFile is source.file and sourceSymbol is source.symbol', async () => {
|
|
170
|
+
const result = await extractFromContractFile(fixtures('source-field.fixture.ts'));
|
|
171
|
+
|
|
172
|
+
assert.equal(result.contracts.length, 1);
|
|
173
|
+
assert.equal(result.contracts[0].sourceFile, 'src/routes/keywords.ts');
|
|
174
|
+
assert.equal(result.contracts[0].sourceSymbol, 'KeywordsResponse');
|
|
175
|
+
});
|
|
156
176
|
});
|
|
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
|
|
5
5
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
6
6
|
import { isContract } from '../contract.js';
|
|
7
7
|
import { hashSchema } from './hash.js';
|
|
8
|
+
import { mapToContractStatus } from './frontmatter.js';
|
|
8
9
|
import type { ExtractionResult } from './frontmatter.js';
|
|
9
10
|
|
|
10
11
|
export async function extractFromContractFile(filePath: string): Promise<ExtractionResult> {
|
|
@@ -62,8 +63,9 @@ export async function extractFromContractFile(filePath: string): Promise<Extract
|
|
|
62
63
|
shape,
|
|
63
64
|
shape_hash,
|
|
64
65
|
imports,
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
contractStatus: mapToContractStatus(exportValue.status),
|
|
67
|
+
sourceFile: exportValue.source?.file || filePath,
|
|
68
|
+
sourceSymbol: exportValue.source?.symbol || exportName,
|
|
67
69
|
});
|
|
68
70
|
}
|
|
69
71
|
|
package/src/reconciler/index.ts
CHANGED
|
@@ -139,10 +139,10 @@ export class Reconciler {
|
|
|
139
139
|
|
|
140
140
|
if (!dependentNode) continue;
|
|
141
141
|
|
|
142
|
-
// Skip nodes that are already reviewing or
|
|
142
|
+
// Skip nodes that are already reviewing or pending, per S011 instructions.
|
|
143
143
|
if (
|
|
144
144
|
dependentNode.status === "needs-review" ||
|
|
145
|
-
dependentNode.status === "
|
|
145
|
+
dependentNode.status === "pending"
|
|
146
146
|
) {
|
|
147
147
|
continue;
|
|
148
148
|
}
|
|
@@ -169,12 +169,12 @@ export class Reconciler {
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
// S012: graph is consistent when no nodes need review and all nodes are stable or
|
|
173
|
-
//
|
|
172
|
+
// S012: graph is consistent when no nodes need review and all nodes are stable or pending.
|
|
173
|
+
// Pending nodes are unverified and are an acceptable stable state.
|
|
174
174
|
return {
|
|
175
175
|
consistent:
|
|
176
176
|
flaggedNodes.length === 0 &&
|
|
177
|
-
nodes.every((n) => n.status === "stable" || n.status === "
|
|
177
|
+
nodes.every((n) => n.status === "stable" || n.status === "pending"),
|
|
178
178
|
flagged: flaggedNodes,
|
|
179
179
|
integrityViolations,
|
|
180
180
|
importSuggestions,
|
package/src/status/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { DBStore, ContractStatus } from '../store/types.js';
|
|
|
3
3
|
export type StatusContractEntry = {
|
|
4
4
|
id: string;
|
|
5
5
|
status: ContractStatus;
|
|
6
|
-
driftClass: 'breaking' | 'stable' | '
|
|
6
|
+
driftClass: 'breaking' | 'stable' | 'pending';
|
|
7
7
|
dependentCount: number;
|
|
8
8
|
dependents: string[];
|
|
9
9
|
};
|
|
@@ -13,7 +13,7 @@ export type StatusReport = {
|
|
|
13
13
|
timestamp: string;
|
|
14
14
|
total: number;
|
|
15
15
|
stable: number;
|
|
16
|
-
|
|
16
|
+
pending: number;
|
|
17
17
|
needsReview: number;
|
|
18
18
|
contracts: StatusContractEntry[];
|
|
19
19
|
};
|
|
@@ -37,7 +37,7 @@ export async function buildStatusReport(store: DBStore): Promise<StatusReport> {
|
|
|
37
37
|
return {
|
|
38
38
|
id: c.id,
|
|
39
39
|
status: c.status,
|
|
40
|
-
driftClass: c.status === 'needs-review' ? 'breaking' : c.status === '
|
|
40
|
+
driftClass: c.status === 'needs-review' ? 'breaking' : c.status === 'pending' ? 'pending' : 'stable',
|
|
41
41
|
dependentCount: dependents.length,
|
|
42
42
|
dependents,
|
|
43
43
|
};
|
|
@@ -48,7 +48,7 @@ export async function buildStatusReport(store: DBStore): Promise<StatusReport> {
|
|
|
48
48
|
timestamp: new Date().toISOString(),
|
|
49
49
|
total: contracts.length,
|
|
50
50
|
stable: contracts.filter((c) => c.status === 'stable').length,
|
|
51
|
-
|
|
51
|
+
pending: contracts.filter((c) => c.status === 'pending').length,
|
|
52
52
|
needsReview: contracts.filter((c) => c.status === 'needs-review').length,
|
|
53
53
|
contracts: entries,
|
|
54
54
|
};
|
package/src/store/sqlite.test.ts
CHANGED
|
@@ -326,3 +326,48 @@ describe('SqliteStore — S50: code_source_file and code_source_symbol', () => {
|
|
|
326
326
|
await store.close();
|
|
327
327
|
});
|
|
328
328
|
});
|
|
329
|
+
|
|
330
|
+
describe('SqliteStore — S62: roadmap → pending migration', () => {
|
|
331
|
+
it('init() migrates legacy roadmap contracts to pending via UPDATE', async () => {
|
|
332
|
+
const store = makeStore();
|
|
333
|
+
await store.init();
|
|
334
|
+
|
|
335
|
+
const node = makeNode({ status: 'stable' });
|
|
336
|
+
await store.upsertNode(node);
|
|
337
|
+
|
|
338
|
+
// Directly insert a contract with status 'roadmap' to simulate legacy data
|
|
339
|
+
const db = (store as any).db;
|
|
340
|
+
db.prepare(
|
|
341
|
+
`INSERT INTO ferret_contracts (id, node_id, shape_hash, shape_schema, type, status)
|
|
342
|
+
VALUES (?, ?, ?, ?, ?, 'roadmap')`,
|
|
343
|
+
).run('api.roadmap-contract', node.id, 'abc', '{}', 'api');
|
|
344
|
+
|
|
345
|
+
// Run the migration UPDATE directly (same as init() does)
|
|
346
|
+
db.exec(`UPDATE ferret_contracts SET status = 'pending' WHERE status = 'roadmap';`);
|
|
347
|
+
|
|
348
|
+
const row = db.prepare('SELECT status FROM ferret_contracts WHERE id = ?').get('api.roadmap-contract') as any;
|
|
349
|
+
assert.equal(row?.status, 'pending');
|
|
350
|
+
|
|
351
|
+
await store.close();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('init() migrates legacy roadmap nodes to pending via UPDATE', async () => {
|
|
355
|
+
const store = makeStore();
|
|
356
|
+
await store.init();
|
|
357
|
+
|
|
358
|
+
// Directly insert a node with status 'roadmap'
|
|
359
|
+
const db = (store as any).db;
|
|
360
|
+
db.prepare(
|
|
361
|
+
`INSERT INTO ferret_nodes (id, file_path, hash, status)
|
|
362
|
+
VALUES (?, ?, ?, 'roadmap')`,
|
|
363
|
+
).run('node-roadmap', 'contracts/roadmap.contract.md', 'abc');
|
|
364
|
+
|
|
365
|
+
// Run migration UPDATE directly (same as init() does)
|
|
366
|
+
db.exec(`UPDATE ferret_nodes SET status = 'pending' WHERE status = 'roadmap';`);
|
|
367
|
+
|
|
368
|
+
const row = db.prepare('SELECT status FROM ferret_nodes WHERE id = ?').get('node-roadmap') as any;
|
|
369
|
+
assert.equal(row?.status, 'pending');
|
|
370
|
+
|
|
371
|
+
await store.close();
|
|
372
|
+
});
|
|
373
|
+
});
|
package/src/store/sqlite.ts
CHANGED
|
@@ -83,11 +83,19 @@ export class SqliteStore implements DBStore {
|
|
|
83
83
|
|
|
84
84
|
try {
|
|
85
85
|
this.db.exec(`ALTER TABLE ferret_contracts ADD COLUMN code_source_file TEXT;`);
|
|
86
|
-
} catch {
|
|
86
|
+
} catch (e: unknown) {
|
|
87
|
+
if (!(e instanceof Error && e.message.includes('duplicate column'))) throw e;
|
|
88
|
+
}
|
|
87
89
|
|
|
88
90
|
try {
|
|
89
91
|
this.db.exec(`ALTER TABLE ferret_contracts ADD COLUMN code_source_symbol TEXT;`);
|
|
90
|
-
} catch {
|
|
92
|
+
} catch (e: unknown) {
|
|
93
|
+
if (!(e instanceof Error && e.message.includes('duplicate column'))) throw e;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// S62 migration: rename legacy 'roadmap' status to 'pending'
|
|
97
|
+
this.db.exec(`UPDATE ferret_contracts SET status = 'pending' WHERE status = 'roadmap';`);
|
|
98
|
+
this.db.exec(`UPDATE ferret_nodes SET status = 'pending' WHERE status = 'roadmap';`);
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
async close(): Promise<void> {
|
package/src/store/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type NodeStatus = 'stable' | 'needs-review' | '
|
|
2
|
-
export type ContractStatus = 'stable' | '
|
|
1
|
+
export type NodeStatus = 'stable' | 'needs-review' | 'pending' | 'blocked';
|
|
2
|
+
export type ContractStatus = 'stable' | 'pending' | 'needs-review';
|
|
3
3
|
|
|
4
4
|
export interface FerretNode {
|
|
5
5
|
id: string;
|