@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,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Three separate unresolvable external references (not exported, no id)
|
|
4
|
+
const ext1 = { value: 'ext1', output: { a: z.string() } };
|
|
5
|
+
const ext2 = { value: 'ext2', output: { b: z.string() } };
|
|
6
|
+
const ext3 = { value: 'ext3', output: { c: z.string() } };
|
|
7
|
+
|
|
8
|
+
export const contractA = {
|
|
9
|
+
value: 'Contract A',
|
|
10
|
+
output: { x: z.string() },
|
|
11
|
+
consumes: [ext1],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const contractB = {
|
|
15
|
+
value: 'Contract B',
|
|
16
|
+
output: { y: z.string() },
|
|
17
|
+
consumes: [ext2],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const contractC = {
|
|
21
|
+
value: 'Contract C',
|
|
22
|
+
output: { zField: z.string() },
|
|
23
|
+
consumes: [ext3],
|
|
24
|
+
};
|
|
@@ -20,7 +20,7 @@ export interface ExtractionResult {
|
|
|
20
20
|
/** TypeScript symbol name (code-first contracts only). */
|
|
21
21
|
sourceSymbol?: string;
|
|
22
22
|
}>;
|
|
23
|
-
extractedBy: 'gray-matter' | 'tree-sitter';
|
|
23
|
+
extractedBy: 'gray-matter' | 'tree-sitter' | 'typescript';
|
|
24
24
|
extractedAt: number; // unix ms
|
|
25
25
|
warning?: 'no-frontmatter';
|
|
26
26
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, expect, it, spyOn } from 'bun:test';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { extractFromContractFile } from './typescript-contract.js';
|
|
5
|
+
|
|
6
|
+
const fixtures = (name: string) => join(import.meta.dir, '__fixtures__', name);
|
|
7
|
+
|
|
8
|
+
describe('extractFromContractFile', () => {
|
|
9
|
+
it('single valid export → one contract with correct id, shape, shape_hash, imports', async () => {
|
|
10
|
+
const result = await extractFromContractFile(fixtures('one-contract.fixture.ts'));
|
|
11
|
+
|
|
12
|
+
assert.equal(result.contracts.length, 1);
|
|
13
|
+
assert.equal(result.contracts[0].id, 'userContract');
|
|
14
|
+
assert.equal(typeof result.contracts[0].shape, 'object');
|
|
15
|
+
assert.equal(typeof result.contracts[0].shape_hash, 'string');
|
|
16
|
+
assert.equal(result.contracts[0].shape_hash.length, 64);
|
|
17
|
+
assert.deepEqual(result.contracts[0].imports, []);
|
|
18
|
+
assert.equal(result.warning, undefined);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('multiple exports — only valid contracts extracted, non-contracts skipped', async () => {
|
|
22
|
+
const result = await extractFromContractFile(fixtures('mixed.fixture.ts'));
|
|
23
|
+
|
|
24
|
+
const ids = result.contracts.map((c) => c.id);
|
|
25
|
+
assert.equal(result.contracts.length, 2);
|
|
26
|
+
assert.ok(ids.includes('validContract'));
|
|
27
|
+
assert.ok(ids.includes('anotherValid'));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('no ferret contracts → empty contracts array + warning: no-frontmatter', async () => {
|
|
31
|
+
const result = await extractFromContractFile(fixtures('empty.fixture.ts'));
|
|
32
|
+
|
|
33
|
+
assert.equal(result.contracts.length, 0);
|
|
34
|
+
assert.equal(result.warning, 'no-frontmatter');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('extractedBy is always "typescript"', async () => {
|
|
38
|
+
const a = await extractFromContractFile(fixtures('one-contract.fixture.ts'));
|
|
39
|
+
const b = await extractFromContractFile(fixtures('empty.fixture.ts'));
|
|
40
|
+
|
|
41
|
+
assert.equal(a.extractedBy, 'typescript');
|
|
42
|
+
assert.equal(b.extractedBy, 'typescript');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('extractedAt is a unix ms timestamp within 1s of Date.now()', async () => {
|
|
46
|
+
const before = Date.now();
|
|
47
|
+
const result = await extractFromContractFile(fixtures('one-contract.fixture.ts'));
|
|
48
|
+
const after = Date.now();
|
|
49
|
+
|
|
50
|
+
assert.ok(result.extractedAt >= before - 1000);
|
|
51
|
+
assert.ok(result.extractedAt <= after + 1000);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('shape hash is deterministic — two runs on same file produce identical hash', async () => {
|
|
55
|
+
// Clear module cache by using a unique query approach isn't possible cleanly in Bun,
|
|
56
|
+
// but the hash must be stable across calls even with the cached module.
|
|
57
|
+
const r1 = await extractFromContractFile(fixtures('one-contract.fixture.ts'));
|
|
58
|
+
const r2 = await extractFromContractFile(fixtures('one-contract.fixture.ts'));
|
|
59
|
+
|
|
60
|
+
assert.equal(r1.contracts[0].shape_hash, r2.contracts[0].shape_hash);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('zod schema with optional fields extracts without throwing', async () => {
|
|
64
|
+
await assert.doesNotReject(() =>
|
|
65
|
+
extractFromContractFile(fixtures('optional-fields.fixture.ts')),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const result = await extractFromContractFile(fixtures('optional-fields.fixture.ts'));
|
|
69
|
+
assert.equal(result.contracts.length, 1);
|
|
70
|
+
assert.equal(result.contracts[0].id, 'optionalFieldsContract');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('same-file consumes reference resolves to the correct export name', async () => {
|
|
74
|
+
const result = await extractFromContractFile(fixtures('same-file-consumes.fixture.ts'));
|
|
75
|
+
|
|
76
|
+
const dependent = result.contracts.find((c) => c.id === 'dependentContract');
|
|
77
|
+
assert.ok(dependent, 'dependentContract not found in result');
|
|
78
|
+
assert.deepEqual(dependent.imports, ['baseContract']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('cross-file reference with explicit id resolves to c.id', async () => {
|
|
82
|
+
const result = await extractFromContractFile(fixtures('cross-file-id.fixture.ts'));
|
|
83
|
+
|
|
84
|
+
assert.equal(result.contracts.length, 1);
|
|
85
|
+
assert.deepEqual(result.contracts[0].imports, ['external.known.id']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('cross-file reference without id emits exactly one warning and returns [unresolved]', async () => {
|
|
89
|
+
const spy = spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
90
|
+
try {
|
|
91
|
+
const result = await extractFromContractFile(fixtures('cross-file-unresolved.fixture.ts'));
|
|
92
|
+
|
|
93
|
+
expect(spy.mock.calls.length).toBe(1);
|
|
94
|
+
assert.deepEqual(result.contracts[0].imports, ['[unresolved]']);
|
|
95
|
+
} finally {
|
|
96
|
+
spy.mockRestore();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('three contracts each with unresolvable cross-file ref → three warnings emitted', async () => {
|
|
101
|
+
const spy = spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
102
|
+
try {
|
|
103
|
+
const result = await extractFromContractFile(fixtures('three-unresolved.fixture.ts'));
|
|
104
|
+
|
|
105
|
+
expect(spy.mock.calls.length).toBe(3);
|
|
106
|
+
assert.equal(result.contracts.length, 3);
|
|
107
|
+
result.contracts.forEach((c) => {
|
|
108
|
+
assert.deepEqual(c.imports, ['[unresolved]']);
|
|
109
|
+
});
|
|
110
|
+
} finally {
|
|
111
|
+
spy.mockRestore();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('filePath and fileType are set correctly in the result', async () => {
|
|
116
|
+
const filePath = fixtures('one-contract.fixture.ts');
|
|
117
|
+
const result = await extractFromContractFile(filePath);
|
|
118
|
+
|
|
119
|
+
assert.equal(result.filePath, filePath);
|
|
120
|
+
assert.equal(result.fileType, 'code');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('fileType is "code" even when no contracts found', async () => {
|
|
124
|
+
const result = await extractFromContractFile(fixtures('empty.fixture.ts'));
|
|
125
|
+
|
|
126
|
+
assert.equal(result.fileType, 'code');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('each contract has sourceFile and sourceSymbol populated', async () => {
|
|
130
|
+
const filePath = fixtures('one-contract.fixture.ts');
|
|
131
|
+
const result = await extractFromContractFile(filePath);
|
|
132
|
+
|
|
133
|
+
assert.equal(result.contracts[0].sourceFile, filePath);
|
|
134
|
+
assert.equal(result.contracts[0].sourceSymbol, 'userContract');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('module that throws at top-level causes extractFromContractFile to reject', async () => {
|
|
138
|
+
await assert.rejects(
|
|
139
|
+
() => extractFromContractFile(fixtures('throws-on-import.fixture.ts')),
|
|
140
|
+
/intentional module-level throw/,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
6
|
+
import { isContract } from '../contract.js';
|
|
7
|
+
import { hashSchema } from './hash.js';
|
|
8
|
+
import type { ExtractionResult } from './frontmatter.js';
|
|
9
|
+
|
|
10
|
+
export async function extractFromContractFile(filePath: string): Promise<ExtractionResult> {
|
|
11
|
+
const mod = (await import(filePath)) as Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
// Pass 1: build Map<contract object reference → export name> for same-file resolution
|
|
14
|
+
const exportNameMap = new Map<object, string>();
|
|
15
|
+
for (const [exportName, exportValue] of Object.entries(mod)) {
|
|
16
|
+
if (isContract(exportValue)) {
|
|
17
|
+
exportNameMap.set(exportValue, exportName);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (exportNameMap.size === 0) {
|
|
22
|
+
return {
|
|
23
|
+
filePath,
|
|
24
|
+
fileType: 'code',
|
|
25
|
+
contracts: [],
|
|
26
|
+
extractedBy: 'typescript',
|
|
27
|
+
extractedAt: Date.now(),
|
|
28
|
+
warning: 'no-frontmatter',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const contracts: ExtractionResult['contracts'] = [];
|
|
33
|
+
|
|
34
|
+
for (const [exportName, exportValue] of Object.entries(mod)) {
|
|
35
|
+
if (!isContract(exportValue)) continue;
|
|
36
|
+
|
|
37
|
+
const shape = zodToJsonSchema(z.object(exportValue.output), { $refStrategy: 'none' });
|
|
38
|
+
const shape_hash = hashSchema(shape);
|
|
39
|
+
|
|
40
|
+
// Pass 2: resolve consumes → import IDs
|
|
41
|
+
const imports: string[] = [];
|
|
42
|
+
if (exportValue.consumes) {
|
|
43
|
+
for (const consumed of exportValue.consumes) {
|
|
44
|
+
const samefile = exportNameMap.get(consumed);
|
|
45
|
+
if (samefile !== undefined) {
|
|
46
|
+
imports.push(samefile);
|
|
47
|
+
} else if (consumed.id !== undefined) {
|
|
48
|
+
imports.push(consumed.id);
|
|
49
|
+
} else {
|
|
50
|
+
process.stderr.write(
|
|
51
|
+
`[specferret] warning: unresolvable consumes reference in '${filePath}' — export '${exportName}' references a contract with no id and no matching same-file export\n`,
|
|
52
|
+
);
|
|
53
|
+
imports.push('[unresolved]');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
contracts.push({
|
|
59
|
+
id: exportName,
|
|
60
|
+
type: 'type',
|
|
61
|
+
shape,
|
|
62
|
+
shape_hash,
|
|
63
|
+
imports,
|
|
64
|
+
sourceFile: filePath,
|
|
65
|
+
sourceSymbol: exportName,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
filePath,
|
|
71
|
+
fileType: 'code',
|
|
72
|
+
contracts,
|
|
73
|
+
extractedBy: 'typescript',
|
|
74
|
+
extractedAt: Date.now(),
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Do not export llm-fallback — dynamic import only, never a default export.
|
|
3
3
|
|
|
4
4
|
export * from './extractor/frontmatter.js';
|
|
5
|
+
export * from './extractor/typescript-contract.js';
|
|
5
6
|
export * from './extractor/typescript.js';
|
|
6
7
|
export * from './extractor/validator.js';
|
|
7
8
|
export * from './extractor/contract-types.js';
|
|
@@ -15,3 +16,7 @@ export * from './reconciler/index.js';
|
|
|
15
16
|
export * from './reconciler/import-suggestions.js';
|
|
16
17
|
export * from './config.js';
|
|
17
18
|
export * from './utils/paths.js';
|
|
19
|
+
export * from './contract.js';
|
|
20
|
+
export * from './status/index.js';
|
|
21
|
+
export * from './watch/index.js';
|
|
22
|
+
export * from './audit/index.js';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { DBStore, ContractStatus } from '../store/types.js';
|
|
2
|
+
|
|
3
|
+
export type StatusContractEntry = {
|
|
4
|
+
id: string;
|
|
5
|
+
status: ContractStatus;
|
|
6
|
+
driftClass: 'breaking' | 'stable' | 'roadmap';
|
|
7
|
+
dependentCount: number;
|
|
8
|
+
dependents: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type StatusReport = {
|
|
12
|
+
version: '2.0';
|
|
13
|
+
timestamp: string;
|
|
14
|
+
total: number;
|
|
15
|
+
stable: number;
|
|
16
|
+
roadmap: number;
|
|
17
|
+
needsReview: number;
|
|
18
|
+
contracts: StatusContractEntry[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function buildStatusReport(store: DBStore): Promise<StatusReport> {
|
|
22
|
+
const [contracts, nodes, deps] = await Promise.all([store.getContracts(), store.getNodes(), store.getDependencies()]);
|
|
23
|
+
|
|
24
|
+
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
25
|
+
const dependentsByContractId = new Map<string, string[]>();
|
|
26
|
+
|
|
27
|
+
for (const dep of deps) {
|
|
28
|
+
const node = nodeById.get(dep.source_node_id);
|
|
29
|
+
if (!node) continue;
|
|
30
|
+
const list = dependentsByContractId.get(dep.target_contract_id) ?? [];
|
|
31
|
+
if (!list.includes(node.file_path)) list.push(node.file_path);
|
|
32
|
+
dependentsByContractId.set(dep.target_contract_id, list);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const entries: StatusContractEntry[] = contracts.map((c) => {
|
|
36
|
+
const dependents = dependentsByContractId.get(c.id) ?? [];
|
|
37
|
+
return {
|
|
38
|
+
id: c.id,
|
|
39
|
+
status: c.status,
|
|
40
|
+
driftClass: c.status === 'needs-review' ? 'breaking' : c.status === 'roadmap' ? 'roadmap' : 'stable',
|
|
41
|
+
dependentCount: dependents.length,
|
|
42
|
+
dependents,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
version: '2.0',
|
|
48
|
+
timestamp: new Date().toISOString(),
|
|
49
|
+
total: contracts.length,
|
|
50
|
+
stable: contracts.filter((c) => c.status === 'stable').length,
|
|
51
|
+
roadmap: contracts.filter((c) => c.status === 'roadmap').length,
|
|
52
|
+
needsReview: contracts.filter((c) => c.status === 'needs-review').length,
|
|
53
|
+
contracts: entries,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildStatusMarkdown(report: StatusReport): string {
|
|
58
|
+
const lines = [
|
|
59
|
+
'# Contract Status',
|
|
60
|
+
'',
|
|
61
|
+
`Generated: ${report.timestamp}`,
|
|
62
|
+
'',
|
|
63
|
+
'| Contract | Status | Drift Class | Dependents |',
|
|
64
|
+
'| --- | --- | --- | --- |',
|
|
65
|
+
...report.contracts.map((c) => `| ${c.id} | ${c.status} | ${c.driftClass} | ${c.dependentCount} |`),
|
|
66
|
+
];
|
|
67
|
+
return lines.join('\n') + '\n';
|
|
68
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Watch layer — file system watcher with debounce for contract files.
|
|
2
|
+
// No business logic. No parsing. No traversal.
|
|
3
|
+
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export interface WatcherOptions {
|
|
8
|
+
specDir: string;
|
|
9
|
+
projectRoot: string;
|
|
10
|
+
debounceMs?: number;
|
|
11
|
+
onChange: (changedFiles: string[]) => void | Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ContractWatcher {
|
|
15
|
+
close: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createContractWatcher(options: WatcherOptions): ContractWatcher {
|
|
19
|
+
const { specDir, projectRoot, debounceMs = 300, onChange } = options;
|
|
20
|
+
const watchDir = path.resolve(projectRoot, specDir);
|
|
21
|
+
|
|
22
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
+
let pendingFiles = new Set<string>();
|
|
24
|
+
let running = false;
|
|
25
|
+
|
|
26
|
+
const watcher = fs.watch(watchDir, { recursive: true }, (_eventType, filename) => {
|
|
27
|
+
if (!filename) return;
|
|
28
|
+
const normalized = filename.replace(/\\/g, '/');
|
|
29
|
+
if (!normalized.endsWith('.contract.md') && !normalized.endsWith('.contract.ts')) return;
|
|
30
|
+
|
|
31
|
+
pendingFiles.add(path.join(specDir, normalized).replace(/\\/g, '/'));
|
|
32
|
+
|
|
33
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
34
|
+
debounceTimer = setTimeout(async () => {
|
|
35
|
+
if (running) return;
|
|
36
|
+
running = true;
|
|
37
|
+
const files = [...pendingFiles];
|
|
38
|
+
pendingFiles = new Set();
|
|
39
|
+
try {
|
|
40
|
+
await onChange(files);
|
|
41
|
+
} finally {
|
|
42
|
+
running = false;
|
|
43
|
+
}
|
|
44
|
+
}, debounceMs);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
close: () => {
|
|
49
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
50
|
+
watcher.close();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|