@specferret/core 0.2.0 → 0.4.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/dist/audit/index.d.ts +27 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +89 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/contract.d.ts +16 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +17 -0
- package/dist/contract.js.map +1 -0
- package/dist/extractor/__fixtures__/cross-file-id.fixture.d.ts +15 -0
- package/dist/extractor/__fixtures__/cross-file-id.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/cross-file-id.fixture.js +9 -0
- package/dist/extractor/__fixtures__/cross-file-id.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.d.ts +14 -0
- package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.js +9 -0
- package/dist/extractor/__fixtures__/cross-file-unresolved.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/empty.fixture.d.ts +6 -0
- package/dist/extractor/__fixtures__/empty.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/empty.fixture.js +5 -0
- package/dist/extractor/__fixtures__/empty.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/external-no-id.fixture.d.ts +8 -0
- package/dist/extractor/__fixtures__/external-no-id.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/external-no-id.fixture.js +7 -0
- package/dist/extractor/__fixtures__/external-no-id.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/external-with-id.fixture.d.ts +9 -0
- package/dist/extractor/__fixtures__/external-with-id.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/external-with-id.fixture.js +8 -0
- package/dist/extractor/__fixtures__/external-with-id.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/mixed.fixture.d.ts +24 -0
- package/dist/extractor/__fixtures__/mixed.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/mixed.fixture.js +15 -0
- package/dist/extractor/__fixtures__/mixed.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/one-contract.fixture.d.ts +10 -0
- package/dist/extractor/__fixtures__/one-contract.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/one-contract.fixture.js +10 -0
- package/dist/extractor/__fixtures__/one-contract.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/optional-fields.fixture.d.ts +10 -0
- package/dist/extractor/__fixtures__/optional-fields.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/optional-fields.fixture.js +10 -0
- package/dist/extractor/__fixtures__/optional-fields.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/same-file-consumes.fixture.d.ts +20 -0
- package/dist/extractor/__fixtures__/same-file-consumes.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/same-file-consumes.fixture.js +11 -0
- package/dist/extractor/__fixtures__/same-file-consumes.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/three-unresolved.fixture.d.ts +38 -0
- package/dist/extractor/__fixtures__/three-unresolved.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/three-unresolved.fixture.js +21 -0
- package/dist/extractor/__fixtures__/three-unresolved.fixture.js.map +1 -0
- package/dist/extractor/__fixtures__/throws-on-import.fixture.d.ts +2 -0
- package/dist/extractor/__fixtures__/throws-on-import.fixture.d.ts.map +1 -0
- package/dist/extractor/__fixtures__/throws-on-import.fixture.js +5 -0
- package/dist/extractor/__fixtures__/throws-on-import.fixture.js.map +1 -0
- package/dist/extractor/frontmatter.d.ts +1 -1
- package/dist/extractor/frontmatter.d.ts.map +1 -1
- package/dist/extractor/typescript-contract.d.ts +3 -0
- package/dist/extractor/typescript-contract.d.ts.map +1 -0
- package/dist/extractor/typescript-contract.js +67 -0
- package/dist/extractor/typescript-contract.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/status/index.d.ts +20 -0
- package/dist/status/index.d.ts.map +1 -0
- package/dist/status/index.js +46 -0
- package/dist/status/index.js.map +1 -0
- package/dist/watch/index.d.ts +11 -0
- package/dist/watch/index.d.ts.map +1 -0
- package/dist/watch/index.js +42 -0
- package/dist/watch/index.js.map +1 -0
- package/package.json +3 -2
- package/src/audit/index.ts +120 -0
- package/src/config.ts +3 -0
- package/src/contract.test.ts +99 -0
- package/src/contract.ts +38 -0
- package/src/extractor/__fixtures__/cross-file-id.fixture.ts +9 -0
- package/src/extractor/__fixtures__/cross-file-unresolved.fixture.ts +9 -0
- package/src/extractor/__fixtures__/empty.fixture.ts +4 -0
- package/src/extractor/__fixtures__/external-no-id.fixture.ts +7 -0
- package/src/extractor/__fixtures__/external-with-id.fixture.ts +8 -0
- package/src/extractor/__fixtures__/mixed.fixture.ts +17 -0
- package/src/extractor/__fixtures__/one-contract.fixture.ts +10 -0
- package/src/extractor/__fixtures__/optional-fields.fixture.ts +10 -0
- package/src/extractor/__fixtures__/same-file-consumes.fixture.ts +12 -0
- package/src/extractor/__fixtures__/three-unresolved.fixture.ts +24 -0
- package/src/extractor/__fixtures__/throws-on-import.fixture.ts +3 -0
- package/src/extractor/frontmatter.ts +1 -1
- package/src/extractor/typescript-contract.test.ts +143 -0
- package/src/extractor/typescript-contract.ts +76 -0
- package/src/index.ts +5 -0
- package/src/status/index.ts +68 -0
- package/src/watch/index.ts +53 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Extractor layer — turns a .contract.ts file into an ExtractionResult.
|
|
2
|
+
// Uses Bun's native import() — no ts-morph, no AST, no compile step.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
5
|
+
import { isContract } from '../contract.js';
|
|
6
|
+
import { hashSchema } from './hash.js';
|
|
7
|
+
export async function extractFromContractFile(filePath) {
|
|
8
|
+
const mod = (await import(filePath));
|
|
9
|
+
// Pass 1: build Map<contract object reference → export name> for same-file resolution
|
|
10
|
+
const exportNameMap = new Map();
|
|
11
|
+
for (const [exportName, exportValue] of Object.entries(mod)) {
|
|
12
|
+
if (isContract(exportValue)) {
|
|
13
|
+
exportNameMap.set(exportValue, exportName);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (exportNameMap.size === 0) {
|
|
17
|
+
return {
|
|
18
|
+
filePath,
|
|
19
|
+
fileType: 'code',
|
|
20
|
+
contracts: [],
|
|
21
|
+
extractedBy: 'typescript',
|
|
22
|
+
extractedAt: Date.now(),
|
|
23
|
+
warning: 'no-frontmatter',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const contracts = [];
|
|
27
|
+
for (const [exportName, exportValue] of Object.entries(mod)) {
|
|
28
|
+
if (!isContract(exportValue))
|
|
29
|
+
continue;
|
|
30
|
+
const shape = zodToJsonSchema(z.object(exportValue.output), { $refStrategy: 'none' });
|
|
31
|
+
const shape_hash = hashSchema(shape);
|
|
32
|
+
// Pass 2: resolve consumes → import IDs
|
|
33
|
+
const imports = [];
|
|
34
|
+
if (exportValue.consumes) {
|
|
35
|
+
for (const consumed of exportValue.consumes) {
|
|
36
|
+
const samefile = exportNameMap.get(consumed);
|
|
37
|
+
if (samefile !== undefined) {
|
|
38
|
+
imports.push(samefile);
|
|
39
|
+
}
|
|
40
|
+
else if (consumed.id !== undefined) {
|
|
41
|
+
imports.push(consumed.id);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
process.stderr.write(`[specferret] warning: unresolvable consumes reference in '${filePath}' — export '${exportName}' references a contract with no id and no matching same-file export\n`);
|
|
45
|
+
imports.push('[unresolved]');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
contracts.push({
|
|
50
|
+
id: exportName,
|
|
51
|
+
type: 'type',
|
|
52
|
+
shape,
|
|
53
|
+
shape_hash,
|
|
54
|
+
imports,
|
|
55
|
+
sourceFile: filePath,
|
|
56
|
+
sourceSymbol: exportName,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
filePath,
|
|
61
|
+
fileType: 'code',
|
|
62
|
+
contracts,
|
|
63
|
+
extractedBy: 'typescript',
|
|
64
|
+
extractedAt: Date.now(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=typescript-contract.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"typescript-contract.js","sourceRoot":"","sources":["../../src/extractor/typescript-contract.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,qEAAqE;AAErE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAGvC,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAAC,QAAgB;IAC5D,MAAM,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,QAAQ,CAAC,CAA4B,CAAC;IAEhE,sFAAsF;IACtF,MAAM,aAAa,GAAG,IAAI,GAAG,EAAkB,CAAC;IAChD,KAAK,MAAM,CAAC,UAAU,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QAC5D,IAAI,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;YAC5B,aAAa,CAAC,GAAG,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;QAC7C,CAAC;IACH,CAAC;IAED,IAAI,aAAa,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QAC7B,OAAO;YACL,QAAQ;YACR,QAAQ,EAAE,MAAM;YAChB,SAAS,EAAE,EAAE;YACb,WAAW,EAAE,YAAY;YACzB,WAAW,EAAE,IAAI,CAAC,GAAG,EAAE;YACvB,OAAO,EAAE,gBAAgB;SAC1B,CAAC;IACJ,CAAC;IAED,MAAM,SAAS,GAAkC,EAAE,CAAC;IAEpD,KAAK,MAAM,CAAC,UAAU,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QAC5D,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC;YAAE,SAAS;QAEvC,MAAM,KAAK,GAAG,eAAe,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,CAAC,CAAC;QACtF,MAAM,UAAU,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;QAErC,wCAAwC;QACxC,MAAM,OAAO,GAAa,EAAE,CAAC;QAC7B,IAAI,WAAW,CAAC,QAAQ,EAAE,CAAC;YACzB,KAAK,MAAM,QAAQ,IAAI,WAAW,CAAC,QAAQ,EAAE,CAAC;gBAC5C,MAAM,QAAQ,GAAG,aAAa,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;gBAC7C,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;oBAC3B,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;gBACzB,CAAC;qBAAM,IAAI,QAAQ,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;oBACrC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;gBAC5B,CAAC;qBAAM,CAAC;oBACN,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,6DAA6D,QAAQ,eAAe,UAAU,uEAAuE,CACtK,CAAC;oBACF,OAAO,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;gBAC/B,CAAC;YACH,CAAC;QACH,CAAC;QAED,SAAS,CAAC,IAAI,CAAC;YACb,EAAE,EAAE,UAAU;YACd,IAAI,EAAE,MAAM;YACZ,KAAK;YACL,UAAU;YACV,OAAO;YACP,UAAU,EAAE,QAAQ;YACpB,YAAY,EAAE,UAAU;SACzB,CAAC,CAAC;IACL,CAAC;IAED,OAAO;QACL,QAAQ;QACR,QAAQ,EAAE,MAAM;QAChB,SAAS;QACT,WAAW,EAAE,YAAY;QACzB,WAAW,EAAE,IAAI,CAAC,GAAG,EAAE;KACxB,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from './extractor/frontmatter.js';
|
|
2
|
+
export * from './extractor/typescript-contract.js';
|
|
2
3
|
export * from './extractor/typescript.js';
|
|
3
4
|
export * from './extractor/validator.js';
|
|
4
5
|
export * from './extractor/contract-types.js';
|
|
@@ -12,4 +13,8 @@ export * from './reconciler/index.js';
|
|
|
12
13
|
export * from './reconciler/import-suggestions.js';
|
|
13
14
|
export * from './config.js';
|
|
14
15
|
export * from './utils/paths.js';
|
|
16
|
+
export * from './contract.js';
|
|
17
|
+
export * from './status/index.js';
|
|
18
|
+
export * from './watch/index.js';
|
|
19
|
+
export * from './audit/index.js';
|
|
15
20
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,4BAA4B,CAAC;AAC3C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,0BAA0B,CAAC;AACzC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,qBAAqB,CAAC;AACpC,cAAc,kCAAkC,CAAC;AACjD,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,oCAAoC,CAAC;AACnD,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oCAAoC,CAAC;AACnD,cAAc,2BAA2B,CAAC;AAC1C,cAAc,0BAA0B,CAAC;AACzC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,qBAAqB,CAAC;AACpC,cAAc,kCAAkC,CAAC;AACjD,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,oCAAoC,CAAC;AACnD,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @specferret/core public API
|
|
2
2
|
// Do not export llm-fallback — dynamic import only, never a default export.
|
|
3
3
|
export * from './extractor/frontmatter.js';
|
|
4
|
+
export * from './extractor/typescript-contract.js';
|
|
4
5
|
export * from './extractor/typescript.js';
|
|
5
6
|
export * from './extractor/validator.js';
|
|
6
7
|
export * from './extractor/contract-types.js';
|
|
@@ -14,4 +15,8 @@ export * from './reconciler/index.js';
|
|
|
14
15
|
export * from './reconciler/import-suggestions.js';
|
|
15
16
|
export * from './config.js';
|
|
16
17
|
export * from './utils/paths.js';
|
|
18
|
+
export * from './contract.js';
|
|
19
|
+
export * from './status/index.js';
|
|
20
|
+
export * from './watch/index.js';
|
|
21
|
+
export * from './audit/index.js';
|
|
17
22
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,4EAA4E;AAE5E,cAAc,4BAA4B,CAAC;AAC3C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,0BAA0B,CAAC;AACzC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,qBAAqB,CAAC;AACpC,cAAc,kCAAkC,CAAC;AACjD,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,oCAAoC,CAAC;AACnD,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,4EAA4E;AAE5E,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oCAAoC,CAAC;AACnD,cAAc,2BAA2B,CAAC;AAC1C,cAAc,0BAA0B,CAAC;AACzC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,qBAAqB,CAAC;AACpC,cAAc,kCAAkC,CAAC;AACjD,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,oCAAoC,CAAC;AACnD,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DBStore, ContractStatus } from '../store/types.js';
|
|
2
|
+
export type StatusContractEntry = {
|
|
3
|
+
id: string;
|
|
4
|
+
status: ContractStatus;
|
|
5
|
+
driftClass: 'breaking' | 'stable' | 'roadmap';
|
|
6
|
+
dependentCount: number;
|
|
7
|
+
dependents: string[];
|
|
8
|
+
};
|
|
9
|
+
export type StatusReport = {
|
|
10
|
+
version: '2.0';
|
|
11
|
+
timestamp: string;
|
|
12
|
+
total: number;
|
|
13
|
+
stable: number;
|
|
14
|
+
roadmap: number;
|
|
15
|
+
needsReview: number;
|
|
16
|
+
contracts: StatusContractEntry[];
|
|
17
|
+
};
|
|
18
|
+
export declare function buildStatusReport(store: DBStore): Promise<StatusReport>;
|
|
19
|
+
export declare function buildStatusMarkdown(report: StatusReport): string;
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/status/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEjE,MAAM,MAAM,mBAAmB,GAAG;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,cAAc,CAAC;IACvB,UAAU,EAAE,UAAU,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC9C,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,KAAK,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,mBAAmB,EAAE,CAAC;CAClC,CAAC;AAEF,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,YAAY,CAAC,CAkC7E;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAWhE"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export async function buildStatusReport(store) {
|
|
2
|
+
const [contracts, nodes, deps] = await Promise.all([store.getContracts(), store.getNodes(), store.getDependencies()]);
|
|
3
|
+
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
4
|
+
const dependentsByContractId = new Map();
|
|
5
|
+
for (const dep of deps) {
|
|
6
|
+
const node = nodeById.get(dep.source_node_id);
|
|
7
|
+
if (!node)
|
|
8
|
+
continue;
|
|
9
|
+
const list = dependentsByContractId.get(dep.target_contract_id) ?? [];
|
|
10
|
+
if (!list.includes(node.file_path))
|
|
11
|
+
list.push(node.file_path);
|
|
12
|
+
dependentsByContractId.set(dep.target_contract_id, list);
|
|
13
|
+
}
|
|
14
|
+
const entries = contracts.map((c) => {
|
|
15
|
+
const dependents = dependentsByContractId.get(c.id) ?? [];
|
|
16
|
+
return {
|
|
17
|
+
id: c.id,
|
|
18
|
+
status: c.status,
|
|
19
|
+
driftClass: c.status === 'needs-review' ? 'breaking' : c.status === 'roadmap' ? 'roadmap' : 'stable',
|
|
20
|
+
dependentCount: dependents.length,
|
|
21
|
+
dependents,
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
version: '2.0',
|
|
26
|
+
timestamp: new Date().toISOString(),
|
|
27
|
+
total: contracts.length,
|
|
28
|
+
stable: contracts.filter((c) => c.status === 'stable').length,
|
|
29
|
+
roadmap: contracts.filter((c) => c.status === 'roadmap').length,
|
|
30
|
+
needsReview: contracts.filter((c) => c.status === 'needs-review').length,
|
|
31
|
+
contracts: entries,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function buildStatusMarkdown(report) {
|
|
35
|
+
const lines = [
|
|
36
|
+
'# Contract Status',
|
|
37
|
+
'',
|
|
38
|
+
`Generated: ${report.timestamp}`,
|
|
39
|
+
'',
|
|
40
|
+
'| Contract | Status | Drift Class | Dependents |',
|
|
41
|
+
'| --- | --- | --- | --- |',
|
|
42
|
+
...report.contracts.map((c) => `| ${c.id} | ${c.status} | ${c.driftClass} | ${c.dependentCount} |`),
|
|
43
|
+
];
|
|
44
|
+
return lines.join('\n') + '\n';
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/status/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface WatcherOptions {
|
|
2
|
+
specDir: string;
|
|
3
|
+
projectRoot: string;
|
|
4
|
+
debounceMs?: number;
|
|
5
|
+
onChange: (changedFiles: string[]) => void | Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export interface ContractWatcher {
|
|
8
|
+
close: () => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function createContractWatcher(options: WatcherOptions): ContractWatcher;
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/watch/index.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,cAAc,GAAG,eAAe,CAmC9E"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Watch layer — file system watcher with debounce for contract files.
|
|
2
|
+
// No business logic. No parsing. No traversal.
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
export function createContractWatcher(options) {
|
|
6
|
+
const { specDir, projectRoot, debounceMs = 300, onChange } = options;
|
|
7
|
+
const watchDir = path.resolve(projectRoot, specDir);
|
|
8
|
+
let debounceTimer = null;
|
|
9
|
+
let pendingFiles = new Set();
|
|
10
|
+
let running = false;
|
|
11
|
+
const watcher = fs.watch(watchDir, { recursive: true }, (_eventType, filename) => {
|
|
12
|
+
if (!filename)
|
|
13
|
+
return;
|
|
14
|
+
const normalized = filename.replace(/\\/g, '/');
|
|
15
|
+
if (!normalized.endsWith('.contract.md') && !normalized.endsWith('.contract.ts'))
|
|
16
|
+
return;
|
|
17
|
+
pendingFiles.add(path.join(specDir, normalized).replace(/\\/g, '/'));
|
|
18
|
+
if (debounceTimer)
|
|
19
|
+
clearTimeout(debounceTimer);
|
|
20
|
+
debounceTimer = setTimeout(async () => {
|
|
21
|
+
if (running)
|
|
22
|
+
return;
|
|
23
|
+
running = true;
|
|
24
|
+
const files = [...pendingFiles];
|
|
25
|
+
pendingFiles = new Set();
|
|
26
|
+
try {
|
|
27
|
+
await onChange(files);
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
running = false;
|
|
31
|
+
}
|
|
32
|
+
}, debounceMs);
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
close: () => {
|
|
36
|
+
if (debounceTimer)
|
|
37
|
+
clearTimeout(debounceTimer);
|
|
38
|
+
watcher.close();
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/watch/index.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,+CAA+C;AAE/C,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAalC,MAAM,UAAU,qBAAqB,CAAC,OAAuB;IAC3D,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,UAAU,GAAG,GAAG,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;IACrE,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;IAEpD,IAAI,aAAa,GAAyC,IAAI,CAAC;IAC/D,IAAI,YAAY,GAAG,IAAI,GAAG,EAAU,CAAC;IACrC,IAAI,OAAO,GAAG,KAAK,CAAC;IAEpB,MAAM,OAAO,GAAG,EAAE,CAAC,KAAK,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,EAAE;QAC/E,IAAI,CAAC,QAAQ;YAAE,OAAO;QACtB,MAAM,UAAU,GAAG,QAAQ,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QAChD,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,cAAc,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,cAAc,CAAC;YAAE,OAAO;QAEzF,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;QAErE,IAAI,aAAa;YAAE,YAAY,CAAC,aAAa,CAAC,CAAC;QAC/C,aAAa,GAAG,UAAU,CAAC,KAAK,IAAI,EAAE;YACpC,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,MAAM,KAAK,GAAG,CAAC,GAAG,YAAY,CAAC,CAAC;YAChC,YAAY,GAAG,IAAI,GAAG,EAAE,CAAC;YACzB,IAAI,CAAC;gBACH,MAAM,QAAQ,CAAC,KAAK,CAAC,CAAC;YACxB,CAAC;oBAAS,CAAC;gBACT,OAAO,GAAG,KAAK,CAAC;YAClB,CAAC;QACH,CAAC,EAAE,UAAU,CAAC,CAAC;IACjB,CAAC,CAAC,CAAC;IAEH,OAAO;QACL,KAAK,EAAE,GAAG,EAAE;YACV,IAAI,aAAa;gBAAE,YAAY,CAAC,aAAa,CAAC,CAAC;YAC/C,OAAO,CAAC,KAAK,EAAE,CAAC;QAClB,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specferret/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "SpecFerret core engine — spec drift detection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"gray-matter": "^4.0.3",
|
|
30
30
|
"tree-sitter": "^0.22.4",
|
|
31
31
|
"tree-sitter-typescript": "^0.23.2",
|
|
32
|
-
"zod": "^3.22.4"
|
|
32
|
+
"zod": "^3.22.4",
|
|
33
|
+
"zod-to-json-schema": "^3.25.2"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"@types/bun": "^1.3.11",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Audit layer — read-only bidirectional drift report.
|
|
2
|
+
// Combines downward drift (reconciler), upward drift (code → spec), and status.
|
|
3
|
+
// No mutations. No side effects beyond reading the store and filesystem.
|
|
4
|
+
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import type { DBStore } from '../store/types.js';
|
|
8
|
+
import type { ImportIntegrityReport, ReconcileReport } from '../reconciler/index.js';
|
|
9
|
+
import type { UpwardDriftResult } from '../extractor/upward-classifier.js';
|
|
10
|
+
import type { StatusReport } from '../status/index.js';
|
|
11
|
+
import type { ExtractionResult } from '../extractor/frontmatter.js';
|
|
12
|
+
import { Reconciler } from '../reconciler/index.js';
|
|
13
|
+
import { classifyUpwardDrift } from '../extractor/upward-classifier.js';
|
|
14
|
+
import { buildStatusReport } from '../status/index.js';
|
|
15
|
+
import { extractContractsFromTypeScript } from '../extractor/typescript.js';
|
|
16
|
+
import { extractFromContractFile } from '../extractor/typescript-contract.js';
|
|
17
|
+
|
|
18
|
+
export interface AuditSummary {
|
|
19
|
+
totalContracts: number;
|
|
20
|
+
stable: number;
|
|
21
|
+
needsReview: number;
|
|
22
|
+
roadmap: number;
|
|
23
|
+
downwardBreaking: number;
|
|
24
|
+
downwardNonBreaking: number;
|
|
25
|
+
upwardBreaking: number;
|
|
26
|
+
upwardNonBreaking: number;
|
|
27
|
+
integrityViolationCount: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AuditReport {
|
|
31
|
+
version: '1.0';
|
|
32
|
+
timestamp: string;
|
|
33
|
+
summary: AuditSummary;
|
|
34
|
+
downwardDrift: ReconcileReport['flagged'];
|
|
35
|
+
upwardDrift: UpwardDriftResult[];
|
|
36
|
+
integrityViolations: ImportIntegrityReport;
|
|
37
|
+
importSuggestions: ReconcileReport['importSuggestions'];
|
|
38
|
+
statusReport: StatusReport;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function buildAuditReport(store: DBStore, projectRoot: string): Promise<AuditReport> {
|
|
42
|
+
const reconciler = new Reconciler(store);
|
|
43
|
+
const reconcileReport = await reconciler.reconcile();
|
|
44
|
+
const statusReport = await buildStatusReport(store);
|
|
45
|
+
|
|
46
|
+
const contracts = await store.getContracts();
|
|
47
|
+
const breakingTriggerIds = new Set(contracts.filter((c) => c.status === 'needs-review').map((c) => c.id));
|
|
48
|
+
|
|
49
|
+
// Upward drift detection — re-extract live TypeScript and compare to declared schema.
|
|
50
|
+
const upwardDrift: UpwardDriftResult[] = [];
|
|
51
|
+
const tsExtractionCache = new Map<string, ExtractionResult>();
|
|
52
|
+
|
|
53
|
+
for (const contract of contracts) {
|
|
54
|
+
if (!contract.code_source_file || !contract.code_source_symbol) continue;
|
|
55
|
+
const sourceAbsPath = path.resolve(projectRoot, contract.code_source_file);
|
|
56
|
+
if (!fs.existsSync(sourceAbsPath)) continue;
|
|
57
|
+
|
|
58
|
+
let codeShape: unknown;
|
|
59
|
+
try {
|
|
60
|
+
if (sourceAbsPath.endsWith('.contract.ts')) {
|
|
61
|
+
let tsExtraction = tsExtractionCache.get(sourceAbsPath);
|
|
62
|
+
if (!tsExtraction) {
|
|
63
|
+
tsExtraction = await extractFromContractFile(sourceAbsPath);
|
|
64
|
+
tsExtractionCache.set(sourceAbsPath, tsExtraction);
|
|
65
|
+
}
|
|
66
|
+
const found = tsExtraction.contracts.find((c) => c.id === contract.code_source_symbol);
|
|
67
|
+
if (!found) continue;
|
|
68
|
+
codeShape = found.shape;
|
|
69
|
+
} else {
|
|
70
|
+
const fileContent = fs.readFileSync(sourceAbsPath, 'utf-8');
|
|
71
|
+
const extraction = extractContractsFromTypeScript(sourceAbsPath, fileContent);
|
|
72
|
+
const found = extraction.contracts.find((c) => c.sourceSymbol === contract.code_source_symbol);
|
|
73
|
+
if (!found) continue;
|
|
74
|
+
codeShape = found.shape;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let declaredSchema: unknown = {};
|
|
81
|
+
try {
|
|
82
|
+
declaredSchema = JSON.parse(contract.shape_schema);
|
|
83
|
+
} catch {}
|
|
84
|
+
|
|
85
|
+
const result = classifyUpwardDrift(contract.id, declaredSchema, codeShape, contract.code_source_file, contract.code_source_symbol);
|
|
86
|
+
if (result.driftClass !== 'NOOP') {
|
|
87
|
+
upwardDrift.push(result);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const downwardBreaking = reconcileReport.flagged.filter((f) => breakingTriggerIds.has(f.triggeredByContractId)).length;
|
|
92
|
+
const downwardNonBreaking = reconcileReport.flagged.filter((f) => !breakingTriggerIds.has(f.triggeredByContractId)).length;
|
|
93
|
+
const upwardBreaking = upwardDrift.filter((d) => d.driftClass === 'BREAKING').length;
|
|
94
|
+
const upwardNonBreaking = upwardDrift.filter((d) => d.driftClass === 'NON_BREAKING').length;
|
|
95
|
+
const integrityViolationCount =
|
|
96
|
+
reconcileReport.integrityViolations.unresolvedImports.length +
|
|
97
|
+
reconcileReport.integrityViolations.selfImports.length +
|
|
98
|
+
reconcileReport.integrityViolations.circularImports.length;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
version: '1.0',
|
|
102
|
+
timestamp: new Date().toISOString(),
|
|
103
|
+
summary: {
|
|
104
|
+
totalContracts: contracts.length,
|
|
105
|
+
stable: statusReport.stable,
|
|
106
|
+
needsReview: statusReport.needsReview,
|
|
107
|
+
roadmap: statusReport.roadmap,
|
|
108
|
+
downwardBreaking,
|
|
109
|
+
downwardNonBreaking,
|
|
110
|
+
upwardBreaking,
|
|
111
|
+
upwardNonBreaking,
|
|
112
|
+
integrityViolationCount,
|
|
113
|
+
},
|
|
114
|
+
downwardDrift: reconcileReport.flagged,
|
|
115
|
+
upwardDrift,
|
|
116
|
+
integrityViolations: reconcileReport.integrityViolations,
|
|
117
|
+
importSuggestions: reconcileReport.importSuggestions,
|
|
118
|
+
statusReport,
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { defineContract, isContract } from './contract.js';
|
|
4
|
+
|
|
5
|
+
describe('isContract', () => {
|
|
6
|
+
it('returns true for a minimal valid object', () => {
|
|
7
|
+
expect(isContract({ value: 'x', output: {} })).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns true for object with id set', () => {
|
|
11
|
+
expect(isContract({ id: 'auth.jwt', value: 'x', output: {} })).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns true for object without id — id is optional and not checked', () => {
|
|
15
|
+
expect(isContract({ value: 'x', output: { name: z.string() } })).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns false for null', () => {
|
|
19
|
+
expect(isContract(null)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns false for undefined', () => {
|
|
23
|
+
expect(isContract(undefined)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns false for a number', () => {
|
|
27
|
+
expect(isContract(42)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns false for a string', () => {
|
|
31
|
+
expect(isContract('string')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns false for object missing value', () => {
|
|
35
|
+
expect(isContract({ output: {} })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns false for object missing output', () => {
|
|
39
|
+
expect(isContract({ value: 'x' })).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns false when value is not a string', () => {
|
|
43
|
+
expect(isContract({ value: 42, output: {} })).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns false when output is null', () => {
|
|
47
|
+
expect(isContract({ value: 'x', output: null })).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns false when output is an array', () => {
|
|
51
|
+
expect(isContract({ value: 'x', output: [] })).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('defineContract', () => {
|
|
56
|
+
it('returns the exact object passed to it when valid', () => {
|
|
57
|
+
const contract = { value: 'x', output: {} };
|
|
58
|
+
expect(defineContract(contract)).toBe(contract);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('does not throw when id is omitted', () => {
|
|
62
|
+
expect(() => defineContract({ value: 'x', output: {} })).not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not throw when id is explicitly undefined', () => {
|
|
66
|
+
expect(() => defineContract({ id: undefined, value: 'x', output: {} })).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does not throw when id is a valid non-empty string', () => {
|
|
70
|
+
expect(() => defineContract({ id: 'auth.jwt', value: 'x', output: {} })).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('throws when id is an empty string', () => {
|
|
74
|
+
expect(() => defineContract({ id: '', value: 'x', output: {} })).toThrow(
|
|
75
|
+
'id must be a non-empty string',
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws when id is whitespace only', () => {
|
|
80
|
+
expect(() => defineContract({ id: ' ', value: 'x', output: {} })).toThrow(
|
|
81
|
+
'id must be a non-empty string',
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('enforces typed invariant argument — compile-time check via typed fixture', () => {
|
|
86
|
+
// If r is not typed as { name: string }, the _: string assignment below is a compile error.
|
|
87
|
+
const contract = defineContract({
|
|
88
|
+
value: 'typed invariant test',
|
|
89
|
+
output: { name: z.string() },
|
|
90
|
+
invariants: [
|
|
91
|
+
(r) => {
|
|
92
|
+
const _: string = r.name;
|
|
93
|
+
return _.length > 0;
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
expect(isContract(contract)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export interface Contract<
|
|
4
|
+
T extends Record<string, z.ZodTypeAny> = Record<string, z.ZodTypeAny>,
|
|
5
|
+
> {
|
|
6
|
+
id?: string;
|
|
7
|
+
value: string;
|
|
8
|
+
output: T;
|
|
9
|
+
invariants?: Array<(r: z.infer<z.ZodObject<T>>) => boolean>;
|
|
10
|
+
consumes?: Contract[];
|
|
11
|
+
forbids?: string[];
|
|
12
|
+
status?: 'complete' | 'active' | 'pending';
|
|
13
|
+
closedBy?: string;
|
|
14
|
+
closedWhen?: string;
|
|
15
|
+
dependsOn?: Contract[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function defineContract<T extends Record<string, z.ZodTypeAny>>(
|
|
19
|
+
contract: Contract<T>,
|
|
20
|
+
): Contract<T> {
|
|
21
|
+
if ('id' in contract && contract.id !== undefined && contract.id.trim() === '') {
|
|
22
|
+
throw new Error('defineContract: id must be a non-empty string if provided');
|
|
23
|
+
}
|
|
24
|
+
return contract;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isContract(value: unknown): value is Contract {
|
|
28
|
+
return (
|
|
29
|
+
typeof value === 'object' &&
|
|
30
|
+
value !== null &&
|
|
31
|
+
'value' in value &&
|
|
32
|
+
typeof (value as Record<string, unknown>).value === 'string' &&
|
|
33
|
+
'output' in value &&
|
|
34
|
+
typeof (value as Record<string, unknown>).output === 'object' &&
|
|
35
|
+
(value as Record<string, unknown>).output !== null &&
|
|
36
|
+
!Array.isArray((value as Record<string, unknown>).output)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { externalWithId } from './external-with-id.fixture.js';
|
|
3
|
+
|
|
4
|
+
// Consumes an external contract that has an explicit id — resolves via c.id
|
|
5
|
+
export const myContract = {
|
|
6
|
+
value: 'Contract consuming known external',
|
|
7
|
+
output: { result: z.string() },
|
|
8
|
+
consumes: [externalWithId],
|
|
9
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { externalNoId } from './external-no-id.fixture.js';
|
|
3
|
+
|
|
4
|
+
// Consumes an external contract with no id — triggers exactly one warning
|
|
5
|
+
export const myContract = {
|
|
6
|
+
value: 'Contract consuming unresolvable external',
|
|
7
|
+
output: { result: z.string() },
|
|
8
|
+
consumes: [externalNoId],
|
|
9
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const validContract = {
|
|
4
|
+
value: 'valid',
|
|
5
|
+
output: { name: z.string() },
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const anotherValid = {
|
|
9
|
+
value: 'another valid',
|
|
10
|
+
output: { count: z.number() },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// These should be skipped by isContract
|
|
14
|
+
export const notAContract = 42;
|
|
15
|
+
export const alsoNotAContract = 'just a string';
|
|
16
|
+
export const missingOutput = { value: 'oops' };
|
|
17
|
+
export const missingValue = { output: { x: z.string() } };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const baseContract = {
|
|
4
|
+
value: 'Base contract',
|
|
5
|
+
output: { token: z.string() },
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const dependentContract = {
|
|
9
|
+
value: 'Dependent contract that consumes baseContract',
|
|
10
|
+
output: { result: z.string() },
|
|
11
|
+
consumes: [baseContract],
|
|
12
|
+
};
|