@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.
- 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__/same-file-dotted-id-consumes.fixture.d.ts +23 -0
- package/dist/extractor/__fixtures__/same-file-dotted-id-consumes.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/same-file-dotted-id-consumes.fixture.js +14 -0
- package/dist/extractor/__fixtures__/same-file-dotted-id-consumes.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 +6 -4
- 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__/same-file-dotted-id-consumes.fixture.ts +15 -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 +40 -7
- package/src/extractor/typescript-contract.ts +6 -4
- 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,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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
};
|