@specferret/core 0.4.2 → 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.
Files changed (60) 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__/same-file-dotted-id-consumes.fixture.d.ts +23 -0
  20. package/dist/extractor/__fixtures__/same-file-dotted-id-consumes.fixture.d.ts.map +1 -0
  21. package/dist/extractor/__fixtures__/same-file-dotted-id-consumes.fixture.js +14 -0
  22. package/dist/extractor/__fixtures__/same-file-dotted-id-consumes.fixture.js.map +1 -0
  23. package/dist/extractor/__fixtures__/source-field.fixture.d.ts +9 -0
  24. package/dist/extractor/__fixtures__/source-field.fixture.d.ts.map +1 -0
  25. package/dist/extractor/__fixtures__/source-field.fixture.js +11 -0
  26. package/dist/extractor/__fixtures__/source-field.fixture.js.map +1 -0
  27. package/dist/extractor/frontmatter.d.ts +7 -0
  28. package/dist/extractor/frontmatter.d.ts.map +1 -1
  29. package/dist/extractor/frontmatter.js +10 -0
  30. package/dist/extractor/frontmatter.js.map +1 -1
  31. package/dist/extractor/typescript-contract.d.ts.map +1 -1
  32. package/dist/extractor/typescript-contract.js +6 -4
  33. package/dist/extractor/typescript-contract.js.map +1 -1
  34. package/dist/reconciler/index.js +5 -5
  35. package/dist/reconciler/index.js.map +1 -1
  36. package/dist/status/index.d.ts +2 -2
  37. package/dist/status/index.js +2 -2
  38. package/dist/store/sqlite.d.ts.map +1 -1
  39. package/dist/store/sqlite.js +11 -2
  40. package/dist/store/sqlite.js.map +1 -1
  41. package/dist/store/types.d.ts +2 -2
  42. package/package.json +1 -1
  43. package/src/audit/index.test.ts +129 -0
  44. package/src/audit/index.ts +2 -2
  45. package/src/context/index.test.ts +34 -5
  46. package/src/context/index.ts +27 -2
  47. package/src/contract.ts +3 -6
  48. package/src/extractor/__fixtures__/active-status.fixture.ts +10 -0
  49. package/src/extractor/__fixtures__/no-status.fixture.ts +9 -0
  50. package/src/extractor/__fixtures__/same-file-dotted-id-consumes.fixture.ts +15 -0
  51. package/src/extractor/__fixtures__/source-field.fixture.ts +11 -0
  52. package/src/extractor/frontmatter.test.ts +71 -0
  53. package/src/extractor/frontmatter.ts +14 -0
  54. package/src/extractor/typescript-contract.test.ts +40 -7
  55. package/src/extractor/typescript-contract.ts +6 -4
  56. package/src/reconciler/index.ts +5 -5
  57. package/src/status/index.ts +4 -4
  58. package/src/store/sqlite.test.ts +45 -0
  59. package/src/store/sqlite.ts +10 -2
  60. 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,15 @@
1
+ import { z } from 'zod';
2
+
3
+ // Export name uses underscores; .id uses dot notation — the standard convention.
4
+ export const tables_dataSource = {
5
+ id: 'tables.dataSource',
6
+ value: 'tables data source',
7
+ output: { data: z.string() },
8
+ };
9
+
10
+ export const tables_ingestionRun = {
11
+ id: 'tables.ingestionRun',
12
+ value: 'tables ingestion run',
13
+ output: { runId: z.string() },
14
+ consumes: [tables_dataSource],
15
+ };
@@ -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
  },
@@ -61,9 +61,7 @@ describe('extractFromContractFile', () => {
61
61
  });
62
62
 
63
63
  it('zod schema with optional fields extracts without throwing', async () => {
64
- await assert.doesNotReject(() =>
65
- extractFromContractFile(fixtures('optional-fields.fixture.ts')),
66
- );
64
+ await assert.doesNotReject(() => extractFromContractFile(fixtures('optional-fields.fixture.ts')));
67
65
 
68
66
  const result = await extractFromContractFile(fixtures('optional-fields.fixture.ts'));
69
67
  assert.equal(result.contracts.length, 1);
@@ -134,10 +132,45 @@ describe('extractFromContractFile', () => {
134
132
  assert.equal(result.contracts[0].sourceSymbol, 'userContract');
135
133
  });
136
134
 
135
+ it('stored contract id uses .id field when present, not export name', async () => {
136
+ const result = await extractFromContractFile(fixtures('same-file-dotted-id-consumes.fixture.ts'));
137
+
138
+ const ids = result.contracts.map((c) => c.id);
139
+ assert.ok(ids.includes('tables.dataSource'), 'expected tables.dataSource stored id');
140
+ assert.ok(ids.includes('tables.ingestionRun'), 'expected tables.ingestionRun stored id');
141
+ assert.ok(!ids.includes('tables_dataSource'), 'export name must not be used as stored id when .id is present');
142
+ assert.ok(!ids.includes('tables_ingestionRun'), 'export name must not be used as stored id when .id is present');
143
+ });
144
+
145
+ it('same-file consumes with dotted .id resolves to .id, not export name', async () => {
146
+ const result = await extractFromContractFile(fixtures('same-file-dotted-id-consumes.fixture.ts'));
147
+
148
+ const ingestionRun = result.contracts.find((c) => c.id === 'tables.ingestionRun');
149
+ assert.ok(ingestionRun, 'tables.ingestionRun not found');
150
+ assert.deepEqual(ingestionRun.imports, ['tables.dataSource']);
151
+ });
152
+
137
153
  it('module that throws at top-level causes extractFromContractFile to reject', async () => {
138
- await assert.rejects(
139
- () => extractFromContractFile(fixtures('throws-on-import.fixture.ts')),
140
- /intentional module-level throw/,
141
- );
154
+ await assert.rejects(() => extractFromContractFile(fixtures('throws-on-import.fixture.ts')), /intentional module-level throw/);
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');
142
175
  });
143
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> {
@@ -44,7 +45,7 @@ export async function extractFromContractFile(filePath: string): Promise<Extract
44
45
  for (const consumed of exportValue.consumes) {
45
46
  const samefile = exportNameMap.get(consumed);
46
47
  if (samefile !== undefined) {
47
- imports.push(samefile);
48
+ imports.push(consumed.id ?? samefile);
48
49
  } else if (consumed.id !== undefined) {
49
50
  imports.push(consumed.id);
50
51
  } else {
@@ -57,13 +58,14 @@ export async function extractFromContractFile(filePath: string): Promise<Extract
57
58
  }
58
59
 
59
60
  contracts.push({
60
- id: exportName,
61
+ id: exportValue.id ?? exportName,
61
62
  type: 'type',
62
63
  shape,
63
64
  shape_hash,
64
65
  imports,
65
- sourceFile: filePath,
66
- sourceSymbol: exportName,
66
+ contractStatus: mapToContractStatus(exportValue.status),
67
+ sourceFile: exportValue.source?.file || filePath,
68
+ sourceSymbol: exportValue.source?.symbol || exportName,
67
69
  });
68
70
  }
69
71
 
@@ -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
  };