@specferret/core 0.4.3 → 0.5.1

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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/audit/index.d.ts +1 -1
  3. package/dist/audit/index.js +1 -1
  4. package/dist/context/index.d.ts +1 -1
  5. package/dist/context/index.d.ts.map +1 -1
  6. package/dist/context/index.js +24 -2
  7. package/dist/context/index.js.map +1 -1
  8. package/dist/contract.d.ts +4 -0
  9. package/dist/contract.d.ts.map +1 -1
  10. package/dist/contract.js.map +1 -1
  11. package/dist/extractor/__fixtures__/active-status.fixture.d.ts +10 -0
  12. package/dist/extractor/__fixtures__/active-status.fixture.d.ts.map +1 -0
  13. package/dist/extractor/__fixtures__/active-status.fixture.js +10 -0
  14. package/dist/extractor/__fixtures__/active-status.fixture.js.map +1 -0
  15. package/dist/extractor/__fixtures__/no-status.fixture.d.ts +9 -0
  16. package/dist/extractor/__fixtures__/no-status.fixture.d.ts.map +1 -0
  17. package/dist/extractor/__fixtures__/no-status.fixture.js +9 -0
  18. package/dist/extractor/__fixtures__/no-status.fixture.js.map +1 -0
  19. package/dist/extractor/__fixtures__/source-field.fixture.d.ts +9 -0
  20. package/dist/extractor/__fixtures__/source-field.fixture.d.ts.map +1 -0
  21. package/dist/extractor/__fixtures__/source-field.fixture.js +11 -0
  22. package/dist/extractor/__fixtures__/source-field.fixture.js.map +1 -0
  23. package/dist/extractor/frontmatter.d.ts +7 -0
  24. package/dist/extractor/frontmatter.d.ts.map +1 -1
  25. package/dist/extractor/frontmatter.js +10 -0
  26. package/dist/extractor/frontmatter.js.map +1 -1
  27. package/dist/extractor/typescript-contract.js +5 -5
  28. package/dist/extractor/typescript-contract.js.map +1 -1
  29. package/dist/reconciler/index.js +5 -5
  30. package/dist/reconciler/index.js.map +1 -1
  31. package/dist/status/index.d.ts +2 -2
  32. package/dist/status/index.js +2 -2
  33. package/dist/store/sqlite.d.ts.map +1 -1
  34. package/dist/store/sqlite.js +11 -2
  35. package/dist/store/sqlite.js.map +1 -1
  36. package/dist/store/types.d.ts +2 -2
  37. package/package.json +2 -3
  38. package/src/audit/index.test.ts +129 -0
  39. package/src/audit/index.ts +2 -2
  40. package/src/context/index.test.ts +34 -5
  41. package/src/context/index.ts +27 -2
  42. package/src/contract.ts +3 -6
  43. package/src/extractor/__fixtures__/active-status.fixture.ts +10 -0
  44. package/src/extractor/__fixtures__/no-status.fixture.ts +9 -0
  45. package/src/extractor/__fixtures__/source-field.fixture.ts +11 -0
  46. package/src/extractor/frontmatter.test.ts +71 -0
  47. package/src/extractor/frontmatter.ts +14 -0
  48. package/src/extractor/typescript-contract.test.ts +20 -0
  49. package/src/extractor/typescript-contract.ts +5 -5
  50. package/src/reconciler/index.ts +5 -5
  51. package/src/status/index.ts +4 -4
  52. package/src/store/sqlite.test.ts +45 -0
  53. package/src/store/sqlite.ts +10 -2
  54. 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
+ });
@@ -19,7 +19,7 @@ export interface AuditSummary {
19
19
  totalContracts: number;
20
20
  stable: number;
21
21
  needsReview: number;
22
- roadmap: number;
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
- roadmap: statusReport.roadmap,
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 "2.0"', async () => {
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, "2.0");
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 known legacy V2 payload without schemaVersion", () => {
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, "2.0");
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: "2.0",
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
  });
@@ -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 = "2.0" as const;
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: V2 payloads created before schemaVersion was introduced.
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,10 @@
1
+ import { z } from 'zod';
2
+
3
+ export const activeContract = {
4
+ id: 'activeContract',
5
+ value: 'Active contract — should map to stable',
6
+ status: 'active' as const,
7
+ output: {
8
+ id: z.string(),
9
+ },
10
+ };
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+
3
+ export const pendingContract = {
4
+ id: 'pendingContract',
5
+ value: 'Pending contract — should map to pending',
6
+ output: {
7
+ id: z.string(),
8
+ },
9
+ };
@@ -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
  });
@@ -2,9 +2,9 @@
2
2
  // Uses Bun's native import() — no ts-morph, no AST, no compile step.
3
3
 
4
4
  import { z } from 'zod';
5
- import { zodToJsonSchema } from 'zod-to-json-schema';
6
5
  import { isContract } from '../contract.js';
7
6
  import { hashSchema } from './hash.js';
7
+ import { mapToContractStatus } from './frontmatter.js';
8
8
  import type { ExtractionResult } from './frontmatter.js';
9
9
 
10
10
  export async function extractFromContractFile(filePath: string): Promise<ExtractionResult> {
@@ -34,8 +34,7 @@ export async function extractFromContractFile(filePath: string): Promise<Extract
34
34
  for (const [exportName, exportValue] of Object.entries(mod)) {
35
35
  if (!isContract(exportValue)) continue;
36
36
 
37
- // zod-to-json-schema@3 types reference zod@3's ZodTypeDef; cast is safe — runtime supports zod@4
38
- const shape = zodToJsonSchema(z.object(exportValue.output) as any, { $refStrategy: 'none' });
37
+ const shape = z.toJSONSchema(z.object(exportValue.output as any), { reused: 'inline' });
39
38
  const shape_hash = hashSchema(shape);
40
39
 
41
40
  // Pass 2: resolve consumes → import IDs
@@ -62,8 +61,9 @@ export async function extractFromContractFile(filePath: string): Promise<Extract
62
61
  shape,
63
62
  shape_hash,
64
63
  imports,
65
- sourceFile: filePath,
66
- sourceSymbol: exportName,
64
+ contractStatus: mapToContractStatus(exportValue.status),
65
+ sourceFile: exportValue.source?.file || filePath,
66
+ sourceSymbol: exportValue.source?.symbol || exportName,
67
67
  });
68
68
  }
69
69
 
@@ -139,10 +139,10 @@ export class Reconciler {
139
139
 
140
140
  if (!dependentNode) continue;
141
141
 
142
- // Skip nodes that are already reviewing or roadmap, per S011 instructions.
142
+ // Skip nodes that are already reviewing or pending, per S011 instructions.
143
143
  if (
144
144
  dependentNode.status === "needs-review" ||
145
- dependentNode.status === "roadmap"
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 roadmap.
173
- // Roadmap nodes are planned-but-not-yet-built and are an acceptable stable state.
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 === "roadmap"),
177
+ nodes.every((n) => n.status === "stable" || n.status === "pending"),
178
178
  flagged: flaggedNodes,
179
179
  integrityViolations,
180
180
  importSuggestions,
@@ -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' | 'roadmap';
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
- roadmap: number;
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 === 'roadmap' ? 'roadmap' : 'stable',
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
- roadmap: contracts.filter((c) => c.status === 'roadmap').length,
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
  };
@@ -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
+ });
@@ -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> {
@@ -1,5 +1,5 @@
1
- export type NodeStatus = 'stable' | 'needs-review' | 'roadmap' | 'blocked';
2
- export type ContractStatus = 'stable' | 'roadmap' | 'needs-review';
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;