@codesema/speccov 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,184 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import ts from 'typescript';
4
+
5
+ export type LocalBuilder = {
6
+ readonly name: string;
7
+ /** A real call to this factory, taken from the spec: `buildUser('usr-a', 'org-demo')`. */
8
+ readonly exampleCall: string | undefined;
9
+ /** The return type as it is written, or `undefined` when it is implicit. */
10
+ readonly returns: string | undefined;
11
+ /** The whole declaration, ready to be copied into the draft. */
12
+ readonly source: string;
13
+ };
14
+
15
+ /**
16
+ * A `dep.method.mockResolvedValue({ items: [...], total: 1 })` taken from the spec.
17
+ *
18
+ * This is the shape the dependency has to return, written by someone who knows it. Copying it
19
+ * avoids inventing it from the type — and above all avoids getting a key wrong.
20
+ */
21
+ export type MockShape = {
22
+ readonly property: string;
23
+ readonly method: string;
24
+ /** The name of the property carrying the array, e.g. `items`. */
25
+ readonly arrayProperty: string;
26
+ /** The first item of the array, as written: `buildUser('usr-a', 'org-demo')`. */
27
+ readonly itemCall: string;
28
+ /** The other properties, as written, ready to be copied. */
29
+ readonly others: readonly { readonly name: string; readonly text: string; readonly wasCount: boolean }[];
30
+ };
31
+
32
+ export type MockBuilder = {
33
+ readonly name: string;
34
+ readonly from: string;
35
+ };
36
+
37
+ export type Harvest = {
38
+ readonly specFile: string;
39
+ /** Double factories imported by the sibling spec: the DI wiring, already written. */
40
+ readonly mockBuilders: readonly MockBuilder[];
41
+ /** Local functions of the spec, candidates as a command or item factory. */
42
+ readonly helpers: readonly string[];
43
+ /**
44
+ * Those same functions, with their written return type and their source.
45
+ *
46
+ * The sibling spec declares its factories locally — `buildScopedQuery()` in a
47
+ * real-world monorepo is not exported. It cannot be imported, so it is copied. And it is
48
+ * only copied on a **return-type equality**, never on its name.
49
+ */
50
+ readonly localBuilders: readonly LocalBuilder[];
51
+ /** The doubles the spec already feeds, by dependency and method. */
52
+ readonly mocks: readonly MockShape[];
53
+ /** Argument order taken from a `new <Class>(…)` in the spec. */
54
+ readonly constructorArguments: readonly string[];
55
+ };
56
+
57
+ /**
58
+ * Harvests the wiring already written in the sibling spec.
59
+ *
60
+ * The bridge is not generation: the doubles, the constructor order and the data factories
61
+ * already exist. The job is to go and fetch them.
62
+ */
63
+ export async function harvestSpec(sourceFile: string, className: string): Promise<Harvest | undefined> {
64
+ const specFile = join(dirname(sourceFile), `${basename(sourceFile, '.ts')}.spec.ts`);
65
+ let source: string;
66
+ try {
67
+ source = await readFile(specFile, 'utf8');
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ return { specFile, ...readSpec(source, specFile, className) };
72
+ }
73
+
74
+ export function readSpec(source: string, specFile: string, className: string): Omit<Harvest, 'specFile'> {
75
+ const parsed = ts.createSourceFile(specFile, source, ts.ScriptTarget.Latest, true);
76
+ const mockBuilders: MockBuilder[] = [];
77
+ const helpers: string[] = [];
78
+ const localBuilders: LocalBuilder[] = [];
79
+ const mocks: MockShape[] = [];
80
+ let constructorArguments: readonly string[] = [];
81
+
82
+ const visit = (node: ts.Node): void => {
83
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
84
+ const from = node.moduleSpecifier.text;
85
+ const bindings = node.importClause?.namedBindings;
86
+ if (bindings !== undefined && ts.isNamedImports(bindings)) {
87
+ for (const element of bindings.elements) {
88
+ if (element.name.text.startsWith('buildMock')) {
89
+ mockBuilders.push({ name: element.name.text, from });
90
+ }
91
+ }
92
+ }
93
+ }
94
+ if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
95
+ helpers.push(node.name.text);
96
+ localBuilders.push({
97
+ name: node.name.text,
98
+ returns: node.type?.getText(parsed),
99
+ source: node.getText(parsed),
100
+ exampleCall: undefined,
101
+ });
102
+ }
103
+ const mock = mockShape(node, parsed);
104
+ if (mock !== undefined) {
105
+ mocks.push(mock);
106
+ }
107
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === className) {
108
+ constructorArguments = (node.arguments ?? []).map((argument) => argument.getText(parsed));
109
+ }
110
+ ts.forEachChild(node, visit);
111
+ };
112
+
113
+ visit(parsed);
114
+
115
+ // Second pass: a real call to each factory. Copy the call rather than inventing
116
+ // arguments — the spec already knows which values make the code run.
117
+ const called = new Map<string, string>();
118
+ const collect = (node: ts.Node): void => {
119
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && !called.has(node.expression.text)) {
120
+ called.set(node.expression.text, node.getText(parsed));
121
+ }
122
+ ts.forEachChild(node, collect);
123
+ };
124
+ collect(parsed);
125
+
126
+ return {
127
+ mockBuilders,
128
+ helpers,
129
+ localBuilders: localBuilders.map((builder) => ({ ...builder, exampleCall: called.get(builder.name) })),
130
+ mocks,
131
+ constructorArguments,
132
+ };
133
+ }
134
+
135
+ /** `users.list.mockResolvedValue({ items: [buildUser(…)], total: 1 })`, taken apart. */
136
+ function mockShape(node: ts.Node, parsed: ts.SourceFile): MockShape | undefined {
137
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) {
138
+ return undefined;
139
+ }
140
+ if (node.expression.name.text !== 'mockResolvedValue' && node.expression.name.text !== 'mockReturnValue') {
141
+ return undefined;
142
+ }
143
+ const target = node.expression.expression;
144
+ if (!ts.isPropertyAccessExpression(target) || !ts.isIdentifier(target.expression)) {
145
+ return undefined;
146
+ }
147
+ const [argument] = node.arguments;
148
+ if (argument === undefined || !ts.isObjectLiteralExpression(argument)) {
149
+ return undefined;
150
+ }
151
+
152
+ let arrayProperty: string | undefined;
153
+ let itemCall: string | undefined;
154
+ let count = 0;
155
+ const others: { name: string; text: string; wasCount: boolean }[] = [];
156
+
157
+ for (const property of argument.properties) {
158
+ if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) {
159
+ return undefined;
160
+ }
161
+ if (arrayProperty === undefined && ts.isArrayLiteralExpression(property.initializer)) {
162
+ const [first] = property.initializer.elements;
163
+ if (first === undefined) {
164
+ return undefined;
165
+ }
166
+ arrayProperty = property.name.text;
167
+ itemCall = first.getText(parsed);
168
+ count = property.initializer.elements.length;
169
+ continue;
170
+ }
171
+ others.push({
172
+ name: property.name.text,
173
+ text: property.initializer.getText(parsed),
174
+ // A property that was exactly the number of items IS that number.
175
+ // No other reading is made: `total: 3` over two items stays `3`.
176
+ wasCount: ts.isNumericLiteral(property.initializer) && Number(property.initializer.text) === count,
177
+ });
178
+ }
179
+
180
+ if (arrayProperty === undefined || itemCall === undefined) {
181
+ return undefined;
182
+ }
183
+ return { property: target.expression.text, method: target.name.text, arrayProperty, itemCall, others };
184
+ }
@@ -0,0 +1,39 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { extractSite, type Extraction } from './extract';
3
+ import { harvestSpec } from './harvest';
4
+ import { readDomain } from './domain';
5
+ import { renderDraft, type Draft } from './render';
6
+ import { renderForbidden } from './render-forbidden';
7
+
8
+ export type DraftResult =
9
+ { readonly kind: 'drafted'; readonly draft: Draft } | { readonly kind: 'refused'; readonly reason: string };
10
+
11
+ export async function draft(file: string, line: number): Promise<DraftResult> {
12
+ const extraction: Extraction = extractSite(file, await readFile(file, 'utf8'), line);
13
+ if (extraction.kind === 'refused') {
14
+ return extraction;
15
+ }
16
+ const { subject } = extraction.site;
17
+ const harvest = await harvestSpec(file, subject.kind === 'class' ? subject.className : subject.functionName);
18
+ return { kind: 'drafted', draft: renderDraft(extraction.site, harvest) };
19
+ }
20
+
21
+ /**
22
+ * Forbidden draft: the domain comes from the type, not from a code site.
23
+ *
24
+ * A forbidden cannot be spotted statically — it is a business sentence. What speccov can
25
+ * do is fill the input space in your stead, exhaustively.
26
+ */
27
+ export function draftForbidden(file: string, typeName: string): DraftResult {
28
+ const read = readDomain(file, typeName);
29
+ if (read.kind === 'refused') {
30
+ return read;
31
+ }
32
+ return { kind: 'drafted', draft: renderForbidden(file, read.typeName, read.fields) };
33
+ }
34
+
35
+ export { readDomain, type DomainField, type DomainRead } from './domain';
36
+ export { renderForbidden } from './render-forbidden';
37
+ export { extractSite, type DraftSite, type Extraction, type Parameter, type Probe, type Subject } from './extract';
38
+ export { harvestSpec, readSpec, type Harvest } from './harvest';
39
+ export { renderDraft, type Draft } from './render';
@@ -0,0 +1,84 @@
1
+ import { basename, dirname, join } from 'node:path';
2
+ import type { DomainField } from './domain';
3
+ import type { Draft } from './render';
4
+
5
+ /**
6
+ * Writes the draft of a ban, domain already filled in.
7
+ *
8
+ * The compiler provided what it knew: the finite values, written out in plain sight in the
9
+ * file. The rest — the business sentence, what is forbidden, a value for the infinite
10
+ * fields — throws at runtime. An unfinished draft does not load, so it cannot announce a
11
+ * green light it never measured.
12
+ *
13
+ * The list is **written out**, not read on the fly: it stays in plain sight, and a role that
14
+ * disappears shows up in review instead of silently dropping a case.
15
+ */
16
+ export function renderForbidden(file: string, typeName: string, fields: readonly DomainField[]): Draft {
17
+ const todos: string[] = [];
18
+ const lines = fields.map((field) => ` ${field.name}: [${valuesOf(field, todos)}],`);
19
+
20
+ // An infinite field weighs its boundary values, plus the one still to be provided.
21
+ const widthOf = (field: DomainField): number => field.values.length + field.edges.length + (field.finite ? 0 : 1);
22
+ const total = fields.reduce((product, field) => product * widthOf(field), 1);
23
+ const shape = fields.map(widthOf).join(' × ');
24
+
25
+ todos.push(`write the business sentence "${typeName}" must never violate.`);
26
+ todos.push(`say what is forbidden for a "${typeName}".`);
27
+
28
+ const content = `/**
29
+ * Draft produced by \`speccov draft --forbidden ${typeName}\`.
30
+ *
31
+ * Domain read from the type: ${shape} = ${total} cases, all exercised.
32
+ * The finite values come from the compiler. What needed a decision throws.
33
+ */
34
+ import { allOf, forbidden } from '@codesema/speccov';
35
+ import type { ${typeName} } from './${basename(file, '.ts')}';
36
+
37
+ function missing(what: string): never {
38
+ throw new Error(\`TODO speccov: \${what}\`);
39
+ }
40
+
41
+ /** TODO speccov: ${todos[todos.length - 2] ?? ''} */
42
+ function intention(): string {
43
+ return missing('write the sentence this ban defends');
44
+ }
45
+
46
+ /** TODO speccov: ${todos[todos.length - 1] ?? ''} */
47
+ function forbids(value: ${typeName}): boolean {
48
+ return missing(\`say what is forbidden — got \${JSON.stringify(value)}\`);
49
+ }
50
+
51
+ export default [
52
+ forbidden<${typeName}>({
53
+ name: '${typeName} · forbidden',
54
+ intention: intention(),
55
+
56
+ // Written out, not read on the fly: a member that disappears shows up here, in review.
57
+ cases: allOf<${typeName}>({
58
+ ${lines.join('\n')}
59
+ }),
60
+
61
+ forbids,
62
+ describe: (value) => JSON.stringify(value),
63
+ }),
64
+ ];
65
+ `;
66
+
67
+ return { path: join(dirname(file), `${basename(file, '.ts')}.rules.ts`), content, todos };
68
+ }
69
+
70
+ /**
71
+ * The values of the field. The infinite part becomes a call that throws, named by its type:
72
+ * the compiler knows `adminAreaId` is a `string`, it does not know that "an area" and "no
73
+ * area" are the two cases that matter.
74
+ */
75
+ function valuesOf(field: DomainField, todos: string[]): string {
76
+ if (field.finite) {
77
+ return field.values.join(', ');
78
+ }
79
+ todos.push(`provide a value for "${field.name}: ${field.declared}".`);
80
+ const asked = `missing('a value for ${field.name}: ${field.declared}')`;
81
+ // The asked value stays first: `allOf()` reads rank 0 as the base case, so an unfinished
82
+ // draft throws on its very first case instead of announcing a green light.
83
+ return [asked, ...field.edges, ...field.values].join(', ');
84
+ }