@specferret/core 0.1.3 → 0.1.4
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 +62 -62
- package/dist/context/index.d.ts +5 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +47 -1
- package/dist/context/index.js.map +1 -1
- package/dist/extractor/__fixtures__/typescript/generics.d.ts +6 -0
- package/dist/extractor/__fixtures__/typescript/generics.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/generics.js +2 -0
- package/dist/extractor/__fixtures__/typescript/generics.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/intersections.d.ts +6 -0
- package/dist/extractor/__fixtures__/typescript/intersections.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/intersections.js +2 -0
- package/dist/extractor/__fixtures__/typescript/intersections.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts +8 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.js +2 -0
- package/dist/extractor/__fixtures__/typescript/optional-nested.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unions.d.ts +5 -0
- package/dist/extractor/__fixtures__/typescript/unions.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unions.js +2 -0
- package/dist/extractor/__fixtures__/typescript/unions.js.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts +7 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js +2 -0
- package/dist/extractor/__fixtures__/typescript/unsupported-syntax.js.map +1 -0
- package/dist/extractor/contract-types.d.ts +5 -0
- package/dist/extractor/contract-types.d.ts.map +1 -0
- package/dist/extractor/contract-types.js +8 -0
- package/dist/extractor/contract-types.js.map +1 -0
- package/dist/extractor/frontmatter.d.ts +2 -1
- package/dist/extractor/frontmatter.d.ts.map +1 -1
- package/dist/extractor/frontmatter.js +12 -6
- package/dist/extractor/frontmatter.js.map +1 -1
- package/dist/extractor/typescript.d.ts +6 -1
- package/dist/extractor/typescript.d.ts.map +1 -1
- package/dist/extractor/typescript.js +403 -185
- package/dist/extractor/typescript.js.map +1 -1
- package/dist/extractor/validator.d.ts +9 -0
- package/dist/extractor/validator.d.ts.map +1 -1
- package/dist/extractor/validator.js +28 -9
- package/dist/extractor/validator.js.map +1 -1
- package/dist/index.d.ts +13 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -12
- package/dist/index.js.map +1 -1
- package/dist/reconciler/index.d.ts +10 -0
- package/dist/reconciler/index.d.ts.map +1 -1
- package/dist/reconciler/index.js +76 -0
- package/dist/reconciler/index.js.map +1 -1
- package/dist/store/sqlite.js +72 -72
- package/package.json +47 -44
- package/src/config.ts +48 -0
- package/src/context/index.test.ts +274 -0
- package/src/context/index.ts +178 -0
- package/src/extractor/__fixtures__/typescript/generics.golden.json +36 -0
- package/src/extractor/__fixtures__/typescript/generics.ts +5 -0
- package/src/extractor/__fixtures__/typescript/intersections.golden.json +18 -0
- package/src/extractor/__fixtures__/typescript/intersections.ts +5 -0
- package/src/extractor/__fixtures__/typescript/optional-nested.golden.json +41 -0
- package/src/extractor/__fixtures__/typescript/optional-nested.ts +7 -0
- package/src/extractor/__fixtures__/typescript/unions.golden.json +30 -0
- package/src/extractor/__fixtures__/typescript/unions.ts +4 -0
- package/src/extractor/__fixtures__/typescript/unsupported-syntax.golden.json +20 -0
- package/src/extractor/__fixtures__/typescript/unsupported-syntax.ts +6 -0
- package/src/extractor/contract-types.ts +11 -0
- package/src/extractor/frontmatter.test.ts +217 -0
- package/src/extractor/frontmatter.ts +77 -0
- package/src/extractor/hash.ts +26 -0
- package/src/extractor/typescript.test.ts +339 -0
- package/src/extractor/typescript.ts +597 -0
- package/src/extractor/validator.test.ts +228 -0
- package/src/extractor/validator.ts +199 -0
- package/src/index.ts +16 -0
- package/src/reconciler/import-suggestions.test.ts +101 -0
- package/src/reconciler/import-suggestions.ts +157 -0
- package/src/reconciler/index.test.ts +248 -0
- package/src/reconciler/index.ts +445 -0
- package/src/store/factory.ts +54 -0
- package/src/store/postgres.ts +84 -0
- package/src/store/sqlite.test.ts +272 -0
- package/src/store/sqlite.ts +278 -0
- package/src/store/types.ts +100 -0
- package/src/utils/paths.ts +24 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { DBStore } from "../store/types.js";
|
|
4
|
+
|
|
5
|
+
export const CONTEXT_VERSION = "2.0" as const;
|
|
6
|
+
export const CONTEXT_SCHEMA_VERSION = "1.0.0" as const;
|
|
7
|
+
|
|
8
|
+
type LegacyFerretContextV2 = {
|
|
9
|
+
version: "2.0";
|
|
10
|
+
generated: string;
|
|
11
|
+
contracts: ContextContract[];
|
|
12
|
+
edges: ContextEdge[];
|
|
13
|
+
needsReview: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface ContextContract {
|
|
17
|
+
id: string;
|
|
18
|
+
type: string;
|
|
19
|
+
shape: unknown;
|
|
20
|
+
status: string;
|
|
21
|
+
specFile: string | null;
|
|
22
|
+
codeFile: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ContextEdge {
|
|
26
|
+
from: string; // source node file_path
|
|
27
|
+
to: string; // target contract id
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FerretContext {
|
|
31
|
+
version: typeof CONTEXT_VERSION;
|
|
32
|
+
schemaVersion: typeof CONTEXT_SCHEMA_VERSION;
|
|
33
|
+
generated: string; // ISO timestamp
|
|
34
|
+
contracts: ContextContract[];
|
|
35
|
+
edges: ContextEdge[];
|
|
36
|
+
needsReview: string[]; // contract IDs currently flagged
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeContext(raw: unknown): FerretContext {
|
|
40
|
+
if (!raw || typeof raw !== "object") {
|
|
41
|
+
throw new Error("ferret: invalid context.json format. Run 'ferret scan' to regenerate .ferret/context.json.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const candidate = raw as Record<string, unknown>;
|
|
45
|
+
const contextVersion = candidate.version;
|
|
46
|
+
if (contextVersion !== CONTEXT_VERSION) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`ferret: unsupported context.json version '${String(contextVersion)}'. Run 'ferret scan' with the current CLI to regenerate .ferret/context.json.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const schemaVersion = candidate.schemaVersion;
|
|
53
|
+
if (schemaVersion !== undefined && schemaVersion !== CONTEXT_SCHEMA_VERSION) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`ferret: unsupported context schemaVersion '${String(schemaVersion)}'. Run 'ferret scan' with the current CLI to migrate .ferret/context.json.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Known migration path: V2 payloads created before schemaVersion was introduced.
|
|
60
|
+
const legacy = candidate as Partial<LegacyFerretContextV2>;
|
|
61
|
+
return {
|
|
62
|
+
version: CONTEXT_VERSION,
|
|
63
|
+
schemaVersion: CONTEXT_SCHEMA_VERSION,
|
|
64
|
+
generated:
|
|
65
|
+
typeof legacy.generated === "string"
|
|
66
|
+
? legacy.generated
|
|
67
|
+
: new Date(0).toISOString(),
|
|
68
|
+
contracts: Array.isArray(legacy.contracts)
|
|
69
|
+
? legacy.contracts
|
|
70
|
+
: [],
|
|
71
|
+
edges: Array.isArray(legacy.edges) ? legacy.edges : [],
|
|
72
|
+
needsReview: Array.isArray(legacy.needsReview)
|
|
73
|
+
? legacy.needsReview
|
|
74
|
+
: [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readContextFile(contextPath: string): FerretContext {
|
|
79
|
+
try {
|
|
80
|
+
const raw = fs.readFileSync(contextPath, "utf-8");
|
|
81
|
+
return normalizeContext(JSON.parse(raw));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
84
|
+
if (message.startsWith("ferret:")) {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
throw new Error(
|
|
88
|
+
`ferret: unable to read .ferret/context.json (${message}). Run 'ferret scan' to regenerate it.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reads the full graph from the store and writes .ferret/context.json.
|
|
95
|
+
* Called automatically at the end of every ferret scan.
|
|
96
|
+
* Silent — no output. If write fails, it logs to stderr and continues.
|
|
97
|
+
*/
|
|
98
|
+
export async function writeContext(
|
|
99
|
+
store: DBStore,
|
|
100
|
+
projectRoot: string,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
const [nodes, contracts, dependencies] = await Promise.all([
|
|
103
|
+
store.getNodes(),
|
|
104
|
+
store.getContracts(),
|
|
105
|
+
store.getDependencies(),
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
// Build a map of node_id → node for quick lookup
|
|
109
|
+
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
110
|
+
|
|
111
|
+
// Map contracts to context shape
|
|
112
|
+
const contextContracts: ContextContract[] = contracts.map((c) => {
|
|
113
|
+
const parentNode = nodeById.get(c.node_id);
|
|
114
|
+
const nodeType = (parentNode as any)?.type ?? "spec"; // nodes don't store type in V2 yet
|
|
115
|
+
|
|
116
|
+
let shape: unknown = {};
|
|
117
|
+
try {
|
|
118
|
+
shape = JSON.parse(c.shape_schema);
|
|
119
|
+
} catch {
|
|
120
|
+
shape = {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
id: c.id,
|
|
125
|
+
type: c.type,
|
|
126
|
+
shape,
|
|
127
|
+
status: c.status,
|
|
128
|
+
specFile:
|
|
129
|
+
nodeType !== "code"
|
|
130
|
+
? (parentNode?.file_path?.replace(/\\/g, "/") ?? null)
|
|
131
|
+
: null,
|
|
132
|
+
codeFile:
|
|
133
|
+
nodeType === "code"
|
|
134
|
+
? (parentNode?.file_path?.replace(/\\/g, "/") ?? null)
|
|
135
|
+
: null,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Build edges: source_node file_path → target_contract_id
|
|
140
|
+
const edges: ContextEdge[] = dependencies.map((d) => {
|
|
141
|
+
const sourceNode = nodeById.get(d.source_node_id);
|
|
142
|
+
return {
|
|
143
|
+
from: (sourceNode?.file_path ?? d.source_node_id).replace(/\\/g, "/"),
|
|
144
|
+
to: d.target_contract_id,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// needsReview: contract IDs whose parent node is flagged needs-review
|
|
149
|
+
const needsReviewNodeIds = new Set(
|
|
150
|
+
nodes.filter((n) => n.status === "needs-review").map((n) => n.id),
|
|
151
|
+
);
|
|
152
|
+
const needsReview = contracts
|
|
153
|
+
.filter(
|
|
154
|
+
(c) => needsReviewNodeIds.has(c.node_id) || c.status === "needs-review",
|
|
155
|
+
)
|
|
156
|
+
.map((c) => c.id);
|
|
157
|
+
|
|
158
|
+
const context: FerretContext = {
|
|
159
|
+
version: CONTEXT_VERSION,
|
|
160
|
+
schemaVersion: CONTEXT_SCHEMA_VERSION,
|
|
161
|
+
generated: new Date().toISOString(),
|
|
162
|
+
contracts: contextContracts,
|
|
163
|
+
edges,
|
|
164
|
+
needsReview,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const ferretDir = path.join(projectRoot, ".ferret");
|
|
168
|
+
const contextPath = path.join(ferretDir, "context.json");
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
if (!fs.existsSync(ferretDir)) {
|
|
172
|
+
fs.mkdirSync(ferretDir, { recursive: true });
|
|
173
|
+
}
|
|
174
|
+
fs.writeFileSync(contextPath, JSON.stringify(context, null, 2), "utf-8");
|
|
175
|
+
} catch (err) {
|
|
176
|
+
process.stderr.write(`⚠ Could not write context.json: ${err}\n`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contracts": [
|
|
3
|
+
{
|
|
4
|
+
"id": "type.src/fixtures/generics/paginatedresponse",
|
|
5
|
+
"type": "type",
|
|
6
|
+
"shape": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"items": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"total": {
|
|
16
|
+
"type": "number"
|
|
17
|
+
},
|
|
18
|
+
"nextCursor": {
|
|
19
|
+
"type": "string"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"required": [
|
|
23
|
+
"items",
|
|
24
|
+
"total"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"sourceSymbol": "PaginatedResponse",
|
|
28
|
+
"filePath": "src/fixtures/generics.ts",
|
|
29
|
+
"idSource": "inferred"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"errors": [],
|
|
33
|
+
"diagnostics": [
|
|
34
|
+
"src/fixtures/generics.ts (PaginatedResponse): Unsupported referenced type 'T'. Falling back to object."
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contracts": [
|
|
3
|
+
{
|
|
4
|
+
"id": "type.src/fixtures/intersections/useridentity",
|
|
5
|
+
"type": "type",
|
|
6
|
+
"shape": {
|
|
7
|
+
"type": "object"
|
|
8
|
+
},
|
|
9
|
+
"sourceSymbol": "UserIdentity",
|
|
10
|
+
"filePath": "src/fixtures/intersections.ts",
|
|
11
|
+
"idSource": "inferred"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"errors": [],
|
|
15
|
+
"diagnostics": [
|
|
16
|
+
"src/fixtures/intersections.ts (UserIdentity): Intersection types are not supported in extraction. Falling back to object."
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contracts": [
|
|
3
|
+
{
|
|
4
|
+
"id": "type.src/fixtures/optional-nested/searchresult",
|
|
5
|
+
"type": "type",
|
|
6
|
+
"shape": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"id": {
|
|
10
|
+
"type": "string"
|
|
11
|
+
},
|
|
12
|
+
"metadata": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {
|
|
15
|
+
"tags": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": {
|
|
18
|
+
"type": "string"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"score": {
|
|
22
|
+
"type": "number"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"required": [
|
|
26
|
+
"score"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"required": [
|
|
31
|
+
"id"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"sourceSymbol": "SearchResult",
|
|
35
|
+
"filePath": "src/fixtures/optional-nested.ts",
|
|
36
|
+
"idSource": "inferred"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"errors": [],
|
|
40
|
+
"diagnostics": []
|
|
41
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contracts": [
|
|
3
|
+
{
|
|
4
|
+
"id": "type.src/fixtures/unions/maybeuser",
|
|
5
|
+
"type": "type",
|
|
6
|
+
"shape": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"id": {
|
|
10
|
+
"type": "object"
|
|
11
|
+
},
|
|
12
|
+
"displayName": {
|
|
13
|
+
"type": "object"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"required": [
|
|
17
|
+
"id"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"sourceSymbol": "MaybeUser",
|
|
21
|
+
"filePath": "src/fixtures/unions.ts",
|
|
22
|
+
"idSource": "inferred"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"errors": [],
|
|
26
|
+
"diagnostics": [
|
|
27
|
+
"src/fixtures/unions.ts (MaybeUser): Union types are not supported in extraction. Falling back to object.",
|
|
28
|
+
"src/fixtures/unions.ts (MaybeUser): Union types are not supported in extraction. Falling back to object."
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contracts": [
|
|
3
|
+
{
|
|
4
|
+
"id": "type.src/fixtures/unsupported-syntax/routemap",
|
|
5
|
+
"type": "type",
|
|
6
|
+
"shape": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {},
|
|
9
|
+
"required": []
|
|
10
|
+
},
|
|
11
|
+
"sourceSymbol": "RouteMap",
|
|
12
|
+
"filePath": "src/fixtures/unsupported-syntax.ts",
|
|
13
|
+
"idSource": "inferred"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"errors": [],
|
|
17
|
+
"diagnostics": [
|
|
18
|
+
"src/fixtures/unsupported-syntax.ts (RouteMap): Unsupported object member type 'index_signature'. Falling back by skipping this member."
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const CONTRACT_TYPES = ['api', 'table', 'type', 'event', 'flow', 'config'] as const;
|
|
2
|
+
|
|
3
|
+
export type ContractType = (typeof CONTRACT_TYPES)[number];
|
|
4
|
+
|
|
5
|
+
export function isContractType(value: unknown): value is ContractType {
|
|
6
|
+
return typeof value === 'string' && (CONTRACT_TYPES as readonly string[]).includes(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatAllowedContractTypes(): string {
|
|
10
|
+
return CONTRACT_TYPES.join(', ');
|
|
11
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'bun:test';
|
|
3
|
+
import { extractFromSpecFile } from './frontmatter.js';
|
|
4
|
+
import { CONTRACT_TYPES } from './contract-types.js';
|
|
5
|
+
|
|
6
|
+
const VALID_SPEC = `---
|
|
7
|
+
ferret:
|
|
8
|
+
id: api.GET/users
|
|
9
|
+
type: api
|
|
10
|
+
shape:
|
|
11
|
+
response:
|
|
12
|
+
type: array
|
|
13
|
+
items:
|
|
14
|
+
type: object
|
|
15
|
+
properties:
|
|
16
|
+
id:
|
|
17
|
+
type: string
|
|
18
|
+
format: uuid
|
|
19
|
+
name:
|
|
20
|
+
type: string
|
|
21
|
+
required: [id, name]
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# Users Endpoint
|
|
25
|
+
|
|
26
|
+
Returns all users.
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const SPEC_WITH_IMPORTS = `---
|
|
30
|
+
ferret:
|
|
31
|
+
id: api.GET/search
|
|
32
|
+
type: api
|
|
33
|
+
shape:
|
|
34
|
+
response:
|
|
35
|
+
type: array
|
|
36
|
+
items:
|
|
37
|
+
type: string
|
|
38
|
+
imports:
|
|
39
|
+
- auth.jwt
|
|
40
|
+
- tables.document
|
|
41
|
+
---
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const SPEC_WITH_UNSUPPORTED_KEYWORD = `---
|
|
45
|
+
ferret:
|
|
46
|
+
id: tables.user
|
|
47
|
+
type: table
|
|
48
|
+
shape:
|
|
49
|
+
type: object
|
|
50
|
+
allOf:
|
|
51
|
+
- type: string
|
|
52
|
+
properties:
|
|
53
|
+
id:
|
|
54
|
+
type: string
|
|
55
|
+
---
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
const SPEC_NO_FRONTMATTER = `# Just a markdown file
|
|
59
|
+
|
|
60
|
+
No frontmatter here. Ferret should skip this.
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const SPEC_MISSING_FIELDS = `---
|
|
64
|
+
ferret:
|
|
65
|
+
id: api.GET/broken
|
|
66
|
+
type: api
|
|
67
|
+
---
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const SPEC_INVALID_TYPE = `---
|
|
71
|
+
ferret:
|
|
72
|
+
id: api.GET/broken
|
|
73
|
+
type: service
|
|
74
|
+
shape:
|
|
75
|
+
type: object
|
|
76
|
+
---
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
describe('extractFromSpecFile — Task 3', () => {
|
|
80
|
+
it('extracts valid frontmatter correctly', () => {
|
|
81
|
+
const result = extractFromSpecFile('contracts/users.contract.md', VALID_SPEC);
|
|
82
|
+
assert.equal(result.filePath, 'contracts/users.contract.md');
|
|
83
|
+
assert.equal(result.fileType, 'spec');
|
|
84
|
+
assert.equal(result.extractedBy, 'gray-matter');
|
|
85
|
+
assert.equal(result.warning, undefined);
|
|
86
|
+
assert.equal(result.contracts.length, 1);
|
|
87
|
+
assert.equal(result.contracts[0].id, 'api.GET/users');
|
|
88
|
+
assert.equal(result.contracts[0].type, 'api');
|
|
89
|
+
assert.notEqual(result.contracts[0].shape_hash, undefined);
|
|
90
|
+
assert.equal(result.contracts[0].shape_hash.length, 64);
|
|
91
|
+
assert.deepEqual(result.contracts[0].imports, []);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('extracts imports correctly', () => {
|
|
95
|
+
const result = extractFromSpecFile('contracts/search.contract.md', SPEC_WITH_IMPORTS);
|
|
96
|
+
assert.deepEqual(result.contracts[0].imports, ['auth.jwt', 'tables.document']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('missing frontmatter returns warning, empty contracts, does not throw', () => {
|
|
100
|
+
const result = extractFromSpecFile('contracts/plain.contract.md', SPEC_NO_FRONTMATTER);
|
|
101
|
+
assert.equal(result.warning, 'no-frontmatter');
|
|
102
|
+
assert.equal(result.contracts.length, 0);
|
|
103
|
+
assert.equal(result.filePath, 'contracts/plain.contract.md');
|
|
104
|
+
assert.equal(result.fileType, 'spec');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('missing required field "shape" throws with field name in message', () => {
|
|
108
|
+
assert.throws(() => extractFromSpecFile('contracts/broken.contract.md', SPEC_MISSING_FIELDS), /shape/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('missing multiple required fields throws with all field names in message', () => {
|
|
112
|
+
const specMissingAll = `---\nferret:\n someField: value\n---\n`;
|
|
113
|
+
assert.throws(() => extractFromSpecFile('contracts/broken.contract.md', specMissingAll), /id.*type.*shape|Missing required/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('invalid top-level contract type throws and lists allowed values', () => {
|
|
117
|
+
assert.throws(
|
|
118
|
+
() => extractFromSpecFile('contracts/broken.contract.md', SPEC_INVALID_TYPE),
|
|
119
|
+
/Invalid contract type 'service'.*Allowed types: api, table, type, event, flow, config/,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('unsupported schema keyword produces warning, does not fail', () => {
|
|
124
|
+
const stderrOutput: string[] = [];
|
|
125
|
+
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
126
|
+
// Capture stderr writes
|
|
127
|
+
process.stderr.write = (chunk: any) => {
|
|
128
|
+
stderrOutput.push(String(chunk));
|
|
129
|
+
return true;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
let result: ReturnType<typeof extractFromSpecFile> | undefined;
|
|
133
|
+
try {
|
|
134
|
+
result = extractFromSpecFile('contracts/complex.contract.md', SPEC_WITH_UNSUPPORTED_KEYWORD);
|
|
135
|
+
} finally {
|
|
136
|
+
process.stderr.write = originalWrite;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
assert.notEqual(result, undefined);
|
|
140
|
+
assert.equal(result!.contracts.length, 1);
|
|
141
|
+
assert.equal(result!.warning, undefined);
|
|
142
|
+
assert.equal(
|
|
143
|
+
stderrOutput.some((line) => line.includes('allOf')),
|
|
144
|
+
true,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('all six allowed types are accepted without error', () => {
|
|
149
|
+
for (const contractType of CONTRACT_TYPES) {
|
|
150
|
+
const spec = `---
|
|
151
|
+
ferret:
|
|
152
|
+
id: test.${contractType}
|
|
153
|
+
type: ${contractType}
|
|
154
|
+
shape:
|
|
155
|
+
type: object
|
|
156
|
+
---
|
|
157
|
+
`;
|
|
158
|
+
assert.doesNotThrow(() => extractFromSpecFile(`contracts/${contractType}.contract.md`, spec));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('extraction is synchronous — the function itself has no async/await', () => {
|
|
163
|
+
// If extractFromSpecFile returns a Promise, this would be a thenable object
|
|
164
|
+
const result = extractFromSpecFile('contracts/users.contract.md', VALID_SPEC);
|
|
165
|
+
assert.equal(result instanceof Promise, false);
|
|
166
|
+
assert.notEqual(typeof (result as any).then, 'function');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('identical files produce identical shape_hash', () => {
|
|
170
|
+
const r1 = extractFromSpecFile('contracts/a.contract.md', VALID_SPEC);
|
|
171
|
+
const r2 = extractFromSpecFile('contracts/b.contract.md', VALID_SPEC);
|
|
172
|
+
assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('different shapes produce different shape_hash', () => {
|
|
176
|
+
const specA = VALID_SPEC;
|
|
177
|
+
const specB = specA.replace('format: uuid', 'format: email');
|
|
178
|
+
const r1 = extractFromSpecFile('contracts/a.contract.md', specA);
|
|
179
|
+
const r2 = extractFromSpecFile('contracts/b.contract.md', specB);
|
|
180
|
+
assert.notEqual(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('property order change in shape does NOT change shape_hash', () => {
|
|
184
|
+
const specA = `---
|
|
185
|
+
ferret:
|
|
186
|
+
id: api.GET/test
|
|
187
|
+
type: api
|
|
188
|
+
shape:
|
|
189
|
+
type: object
|
|
190
|
+
properties:
|
|
191
|
+
id:
|
|
192
|
+
type: string
|
|
193
|
+
name:
|
|
194
|
+
type: string
|
|
195
|
+
required: [id, name]
|
|
196
|
+
---
|
|
197
|
+
`;
|
|
198
|
+
const specB = `---
|
|
199
|
+
ferret:
|
|
200
|
+
id: api.GET/test
|
|
201
|
+
type: api
|
|
202
|
+
shape:
|
|
203
|
+
type: object
|
|
204
|
+
properties:
|
|
205
|
+
name:
|
|
206
|
+
type: string
|
|
207
|
+
id:
|
|
208
|
+
type: string
|
|
209
|
+
required: [id, name]
|
|
210
|
+
---
|
|
211
|
+
`;
|
|
212
|
+
const r1 = extractFromSpecFile('contracts/a.contract.md', specA);
|
|
213
|
+
const r2 = extractFromSpecFile('contracts/b.contract.md', specB);
|
|
214
|
+
// Keys are sorted before hashing — order change is a no-change
|
|
215
|
+
assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Synchronous only. No async. No await. Anywhere in this file.
|
|
2
|
+
// Extractor layer — turns a spec file into an ExtractionResult using gray-matter.
|
|
3
|
+
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import { validateContractType, validateFerretSchema } from './validator.js';
|
|
6
|
+
import { hashSchema } from './hash.js';
|
|
7
|
+
import type { ContractType } from './contract-types.js';
|
|
8
|
+
|
|
9
|
+
export interface ExtractionResult {
|
|
10
|
+
filePath: string;
|
|
11
|
+
fileType: 'spec' | 'code';
|
|
12
|
+
contracts: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
type: ContractType;
|
|
15
|
+
shape: object;
|
|
16
|
+
shape_hash: string;
|
|
17
|
+
imports: string[];
|
|
18
|
+
}>;
|
|
19
|
+
extractedBy: 'gray-matter' | 'tree-sitter';
|
|
20
|
+
extractedAt: number; // unix ms
|
|
21
|
+
warning?: 'no-frontmatter';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extracts a FerretContract from a spec file's YAML frontmatter.
|
|
26
|
+
*
|
|
27
|
+
* Rules:
|
|
28
|
+
* - No frontmatter → return empty contracts + warning: 'no-frontmatter'
|
|
29
|
+
* - Missing required field (id, type, shape) → throw with field name in message
|
|
30
|
+
* - Unsupported schema keyword → warn via validateFerretSchema, continue
|
|
31
|
+
* - Identical files → identical shape_hash (deterministic)
|
|
32
|
+
*/
|
|
33
|
+
export function extractFromSpecFile(filePath: string, fileContent: string): ExtractionResult {
|
|
34
|
+
const { data } = matter(fileContent);
|
|
35
|
+
const ferret = data?.ferret;
|
|
36
|
+
|
|
37
|
+
if (!ferret) {
|
|
38
|
+
return {
|
|
39
|
+
filePath,
|
|
40
|
+
fileType: 'spec',
|
|
41
|
+
contracts: [],
|
|
42
|
+
extractedBy: 'gray-matter',
|
|
43
|
+
extractedAt: Date.now(),
|
|
44
|
+
warning: 'no-frontmatter',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const missingFields = ['id', 'type', 'shape'].filter((f) => !ferret[f]);
|
|
49
|
+
if (missingFields.length > 0) {
|
|
50
|
+
throw new Error(`Missing required frontmatter fields in ${filePath}: ${missingFields.join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const typeValidation = validateContractType(ferret.type, filePath);
|
|
54
|
+
if (!typeValidation.valid) {
|
|
55
|
+
throw new Error(typeValidation.error);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// validateFerretSchema never throws — only warns on unsupported keywords
|
|
59
|
+
const validation = validateFerretSchema(ferret.shape, filePath);
|
|
60
|
+
validation.warnings.forEach((w) => process.stderr.write(w + '\n'));
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
filePath,
|
|
64
|
+
fileType: 'spec',
|
|
65
|
+
contracts: [
|
|
66
|
+
{
|
|
67
|
+
id: ferret.id as string,
|
|
68
|
+
type: typeValidation.value,
|
|
69
|
+
shape: ferret.shape as object,
|
|
70
|
+
shape_hash: hashSchema(ferret.shape),
|
|
71
|
+
imports: Array.isArray(ferret.imports) ? (ferret.imports as string[]) : [],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
extractedBy: 'gray-matter',
|
|
75
|
+
extractedAt: Date.now(),
|
|
76
|
+
};
|
|
77
|
+
}
|