@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.
- package/LICENSE +21 -0
- package/README.md +607 -2
- package/bin/speccov.mjs +80 -0
- package/package.json +63 -5
- package/src/adapters/typeorm.ts +90 -0
- package/src/answers.ts +126 -0
- package/src/cli.ts +417 -0
- package/src/complexity.ts +75 -0
- package/src/counter.ts +16 -0
- package/src/domain.ts +70 -0
- package/src/draft/domain.ts +138 -0
- package/src/draft/extract.ts +550 -0
- package/src/draft/harvest.ts +184 -0
- package/src/draft/index.ts +39 -0
- package/src/draft/render-forbidden.ts +84 -0
- package/src/draft/render.ts +530 -0
- package/src/draft/sweep.ts +81 -0
- package/src/draft/trace.ts +219 -0
- package/src/forbidden.ts +77 -0
- package/src/gauge.ts +48 -0
- package/src/growth.ts +64 -0
- package/src/index.ts +21 -0
- package/src/json.ts +121 -0
- package/src/metamorphic.ts +68 -0
- package/src/mutate/mutators.ts +166 -0
- package/src/mutate/report.ts +52 -0
- package/src/mutate/run.ts +79 -0
- package/src/report.ts +143 -0
- package/src/resource.ts +76 -0
- package/src/rule.ts +129 -0
- package/src/run.ts +70 -0
- package/src/scan/answers.ts +152 -0
- package/src/scan/climb.ts +26 -0
- package/src/scan/flow.ts +65 -0
- package/src/scan/guarantees.ts +234 -0
- package/src/scan/ignored.ts +40 -0
- package/src/scan/index.ts +213 -0
- package/src/scan/program.ts +137 -0
- package/src/scan/report.ts +267 -0
- package/src/scan/shapes.ts +1139 -0
- package/src/scan/speccovignore.ts +65 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { basename, dirname, join } from 'node:path';
|
|
2
|
+
import type { DraftSite, Parameter, Probe, Subject } from './extract';
|
|
3
|
+
import type { Harvest } from './harvest';
|
|
4
|
+
|
|
5
|
+
export type Draft = {
|
|
6
|
+
/** Where to write the draft: next to the use case, like its spec. */
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly content: string;
|
|
9
|
+
/** The decisions left to a human. While one of them remains, the measurement is worth nothing. */
|
|
10
|
+
readonly todos: readonly string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A ceiling is not derived, and neither is the reason that set it. Letting both throw at module
|
|
15
|
+
* load is deliberate: `resource()` validates its ceiling at construction, so an unfinished draft
|
|
16
|
+
* cannot even load — there is no window in which it would measure green by accident.
|
|
17
|
+
*/
|
|
18
|
+
const CEILING_STUB = `
|
|
19
|
+
/** TODO speccov: choose the tolerated concurrency peak. */
|
|
20
|
+
function ceiling(): number {
|
|
21
|
+
throw new Error('TODO speccov: choose the tolerated concurrency peak.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* TODO speccov: say **where this ceiling comes from**, never paraphrase it.
|
|
26
|
+
*
|
|
27
|
+
* This is the sentence the agent reads when the rule breaks. "Peak never exceeds 4" teaches it
|
|
28
|
+
* nothing: it cannot tell whether 4 is sacred or arbitrary, so it may "fix" the failure by
|
|
29
|
+
* raising the threshold. The reason — where the number comes from — tells it the fix is to cap
|
|
30
|
+
* the call.
|
|
31
|
+
*/
|
|
32
|
+
function intention(): string {
|
|
33
|
+
throw new Error('TODO speccov: say where this ceiling comes from.');
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wrap rather than replace: the only way to measure a method of the subject itself.
|
|
39
|
+
*
|
|
40
|
+
* A double would return an invented value there, and the rest of the code would measure that
|
|
41
|
+
* invention. Here the real method is still called: the probe only adds a count around it,
|
|
42
|
+
* and what is observed stays the real code.
|
|
43
|
+
*/
|
|
44
|
+
export const AROUND_HELPER = `
|
|
45
|
+
/** Wraps the real method: the probe counts without changing what the code does. */
|
|
46
|
+
function around(target: object, method: string, wrap: (call: () => unknown) => unknown): void {
|
|
47
|
+
const holder = target as unknown as Record<string, (...args: readonly unknown[]) => unknown>;
|
|
48
|
+
const original = holder[method];
|
|
49
|
+
if (typeof original !== 'function') {
|
|
50
|
+
throw new Error(\`TODO speccov: \${method}() is not found on the subject.\`);
|
|
51
|
+
}
|
|
52
|
+
const bound = original.bind(target);
|
|
53
|
+
holder[method] = (...args: readonly unknown[]) => wrap(() => bound(...args));
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The double of a parameter: it knows how to do one thing, carry the probe.
|
|
59
|
+
*
|
|
60
|
+
* That is what makes the type assertion honest. It does not pretend to *be* the
|
|
61
|
+
* dependency: it refuses everything it is not, and names itself doing so. No invented
|
|
62
|
+
* value and no silent `undefined` therefore reaches the measured code — the draft
|
|
63
|
+
* stops before that, saying which member is missing.
|
|
64
|
+
*/
|
|
65
|
+
export const ONLY_HELPER = `
|
|
66
|
+
/** A single-member double: everything else throws, so nothing invented enters the code. */
|
|
67
|
+
function only<T>(name: string, method?: string, probe?: (...args: readonly unknown[]) => unknown): T {
|
|
68
|
+
return new Proxy({} as Record<PropertyKey, unknown>, {
|
|
69
|
+
get: (_target, property) => {
|
|
70
|
+
if (method !== undefined && property === method) {
|
|
71
|
+
return probe;
|
|
72
|
+
}
|
|
73
|
+
// A symbol is a language protocol, not a call from the measured code.
|
|
74
|
+
if (typeof property === 'symbol') {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const doubled = method === undefined ? \`no member of \${name} is doubled\` : \`only \${name}.\${method}() carries the probe\`;
|
|
78
|
+
throw new Error(\`TODO speccov: \${name}.\${String(property)}() is not doubled — \${doubled}.\`);
|
|
79
|
+
},
|
|
80
|
+
}) as T;
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
/** The names the draft writes itself: a parameter of the same name would take their place. */
|
|
85
|
+
const RESERVED = new Set([
|
|
86
|
+
'size',
|
|
87
|
+
'items',
|
|
88
|
+
'subject',
|
|
89
|
+
'gauge',
|
|
90
|
+
'counter',
|
|
91
|
+
'missing',
|
|
92
|
+
'around',
|
|
93
|
+
'collectionOf',
|
|
94
|
+
'ceiling',
|
|
95
|
+
'intention',
|
|
96
|
+
'command',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Writes the draft rule.
|
|
101
|
+
*
|
|
102
|
+
* Two guarantees hold everything up: what is derivable is in place, and **what is not
|
|
103
|
+
* throws at runtime**. An unfinished draft fails loudly, never silently — a subject that
|
|
104
|
+
* "works" while measuring the wrong thing would be worse than no subject at all.
|
|
105
|
+
*/
|
|
106
|
+
export function renderDraft(site: DraftSite, harvest: Harvest | undefined): Draft {
|
|
107
|
+
const todos: string[] = [];
|
|
108
|
+
const builders = harvest?.mockBuilders ?? [];
|
|
109
|
+
const fanout = site.shape === 'unbounded-fanout';
|
|
110
|
+
const { subject, probe } = site;
|
|
111
|
+
|
|
112
|
+
const helpers = harvest?.helpers ?? [];
|
|
113
|
+
const hint = helpers.length > 0 ? ` The neighbouring spec exposes: ${helpers.join(', ')}.` : '';
|
|
114
|
+
|
|
115
|
+
const wiring =
|
|
116
|
+
subject.kind === 'class'
|
|
117
|
+
? wireConstructor(subject, builders, probe, fanout, todos)
|
|
118
|
+
: { lines: [] as string[], used: [] as string[], args: '', mocked: false, usesOnly: false };
|
|
119
|
+
const known = new Map(wiring.used.map((name) => [name, builders.find((builder) => builder.name === name)?.from ?? '']));
|
|
120
|
+
|
|
121
|
+
const imports = [
|
|
122
|
+
fanout
|
|
123
|
+
? "import { resource, Gauge } from '@codesema/speccov';"
|
|
124
|
+
: "import { complexity, Counter } from '@codesema/speccov';",
|
|
125
|
+
...[...known].sort().map(([name, from]) => `import { ${name} } from '${from}';`),
|
|
126
|
+
`import { ${subject.kind === 'class' ? subject.className : subject.functionName} } from './${basename(site.file, '.ts')}';`,
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
if (fanout) {
|
|
130
|
+
// A ceiling is a decision, never a derivation: `resource()` is `origin: 'declared'`
|
|
131
|
+
// for that reason. Letting it throw is refusing to guess it.
|
|
132
|
+
todos.push('choose the tolerated concurrency peak.');
|
|
133
|
+
// A ceiling without its reason is a bare number: the agent reading the failure cannot
|
|
134
|
+
// tell a grounded bound from an arbitrary threshold, and raises the threshold.
|
|
135
|
+
todos.push('say where this ceiling comes from.');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const collection = renderCollection(site, harvest, probe, fanout, hint, todos);
|
|
139
|
+
const entry = renderEntryArguments(site, harvest, todos, hint);
|
|
140
|
+
const invocation =
|
|
141
|
+
subject.kind === 'class'
|
|
142
|
+
? ` await subject.${subject.methodName}(${entry.args.join(', ')});`
|
|
143
|
+
: ` await ${subject.functionName}(${entry.args.join(', ')});`;
|
|
144
|
+
|
|
145
|
+
const content = `/**
|
|
146
|
+
* Draft produced by \`speccov draft\`.
|
|
147
|
+
*
|
|
148
|
+
* Site: ${site.file}:${site.line}
|
|
149
|
+
* \`${site.collection}.map(…)\` calls \`${describe(probe)}\` per item.
|
|
150
|
+
*
|
|
151
|
+
* What was derivable is in place. What needed a decision throws at runtime:
|
|
152
|
+
* an unfinished draft fails loudly, it never produces a misleading green light.
|
|
153
|
+
*/
|
|
154
|
+
${imports.join('\n')}
|
|
155
|
+
|
|
156
|
+
${fanout ? 'const gauge = new Gauge();' : 'const counter = new Counter();'}
|
|
157
|
+
${fanout ? CEILING_STUB : ''}
|
|
158
|
+
${collection.block}
|
|
159
|
+
|
|
160
|
+
${entry.blocks.join('\n\n')}
|
|
161
|
+
|
|
162
|
+
function missing(what: string): never {
|
|
163
|
+
throw new Error(\`TODO speccov: \${what}\`);
|
|
164
|
+
}
|
|
165
|
+
${probe.kind === 'own' ? AROUND_HELPER : ''}${probe.kind === 'parameter' || wiring.usesOnly ? ONLY_HELPER : ''}
|
|
166
|
+
export default [
|
|
167
|
+
${fanout ? 'resource' : 'complexity'}({
|
|
168
|
+
name: '${label(subject)} · ${fanout ? 'concurrency' : 'reads'} via ${short(probe)}',
|
|
169
|
+
unit: '${fanout ? 'concurrent reads' : 'reads'}',
|
|
170
|
+
${fanout ? ' ceiling: ceiling(),\n intention: intention(),\n gauge,' : ' counter,'}
|
|
171
|
+
subject: async (size) => {
|
|
172
|
+
${[...wiring.lines, ...instantiate(subject, wiring.args), ...probeLines(probe, subject, fanout, wiring.mocked, todos), ...collection.feeding].join('\n')}
|
|
173
|
+
|
|
174
|
+
${invocation}
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
];
|
|
178
|
+
`;
|
|
179
|
+
|
|
180
|
+
// Absent blocks leave their blank line behind them: a draft gets read, and three blank
|
|
181
|
+
// lines tell of a hole that does not exist.
|
|
182
|
+
return {
|
|
183
|
+
path: join(dirname(site.file), `${basename(site.file, '.ts')}.rules.ts`),
|
|
184
|
+
content: content.replace(/\n{3,}/gu, '\n\n'),
|
|
185
|
+
todos,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The constructor doubles.
|
|
191
|
+
*
|
|
192
|
+
* A factory harvested from the neighbouring spec returns a runner mock, and that is the only
|
|
193
|
+
* thing that allows a `.mockImplementation()` further down. Without a factory there is no
|
|
194
|
+
* mock: writing `missing(…)` threw on the very first line — the subject could not even be
|
|
195
|
+
* instantiated, and the rest of the draft was dead code that did not compile. The `only()`
|
|
196
|
+
* double does exist, and throws only on the member actually asked for.
|
|
197
|
+
*/
|
|
198
|
+
function wireConstructor(
|
|
199
|
+
subject: Extract<Subject, { kind: 'class' }>,
|
|
200
|
+
builders: Harvest['mockBuilders'],
|
|
201
|
+
probe: Probe,
|
|
202
|
+
fanout: boolean,
|
|
203
|
+
todos: string[],
|
|
204
|
+
): { lines: string[]; used: string[]; args: string; mocked: boolean; usesOnly: boolean } {
|
|
205
|
+
const used: string[] = [];
|
|
206
|
+
let mocked = false;
|
|
207
|
+
let usesOnly = false;
|
|
208
|
+
const lines = subject.constructorParameters.flatMap(({ name, type }, index) => {
|
|
209
|
+
if (name === '') {
|
|
210
|
+
todos.push(`provide argument ${index + 1} of the ${subject.className} constructor`);
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
const builder = builders.find(({ name: candidate }) => matches(candidate, type));
|
|
214
|
+
if (builder !== undefined) {
|
|
215
|
+
used.push(builder.name);
|
|
216
|
+
mocked ||= probe.kind === 'injected' && probe.property === name;
|
|
217
|
+
return [` const ${name} = ${builder.name}();`];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
usesOnly = true;
|
|
221
|
+
const wanted = `ConstructorParameters<typeof ${subject.className}>[${index}]`;
|
|
222
|
+
if (probe.kind === 'injected' && probe.property === name) {
|
|
223
|
+
// The probe goes into the double: it is the "provide a double" decision that
|
|
224
|
+
// disappears, not the one about the returned value. That one stays written below.
|
|
225
|
+
todos.push(valueTodo(name, probe.method));
|
|
226
|
+
return [
|
|
227
|
+
` // No factory harvested: the double carries the probe and refuses everything else.`,
|
|
228
|
+
` const ${name} = only<${wanted}>('${name}', '${probe.method}', () =>`,
|
|
229
|
+
` ${wrapValue(fanout, `missing('value returned by ${name}.${probe.method}()')`)},`,
|
|
230
|
+
` );`,
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
// The hole is marked in the text like all the others: whoever fills it must be able to
|
|
234
|
+
// find in the file every line the list announces, and the other way round.
|
|
235
|
+
todos.push(`provide a double for "${name}: ${type}"`);
|
|
236
|
+
return [
|
|
237
|
+
` // TODO speccov: provide a double for "${name}: ${type}" — no member is doubled.`,
|
|
238
|
+
` const ${name} = only<${wanted}>('${name}');`,
|
|
239
|
+
];
|
|
240
|
+
});
|
|
241
|
+
const args = subject.constructorParameters
|
|
242
|
+
.map(({ name }, index) => (name === '' ? `missing('constructor argument ${index + 1}')` : name))
|
|
243
|
+
.join(', ');
|
|
244
|
+
return { lines, used, args, mocked, usesOnly };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function instantiate(subject: Subject, args: string): string[] {
|
|
248
|
+
if (subject.kind !== 'class') {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
// A class without a constructor instantiates with nothing: requiring injection ruled out
|
|
252
|
+
// half of the real classes for a constraint the code does not impose.
|
|
253
|
+
return [` const subject = new ${subject.className}(${args});`, ''];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Placing the probe, and this is where the carrier decides everything.
|
|
258
|
+
*
|
|
259
|
+
* An injected dependency gets replaced — the value it returned becomes a hole. A method of
|
|
260
|
+
* the subject gets wrapped, and nothing is invented. A parameter receives a double that
|
|
261
|
+
* carries **only** the probed member: any other call breaks, so an incomplete draft cannot
|
|
262
|
+
* measure green by accident. The iterated item has nothing to place here: the probe is
|
|
263
|
+
* already in the collection the draft builds.
|
|
264
|
+
*/
|
|
265
|
+
function probeLines(probe: Probe, subject: Subject, fanout: boolean, mocked: boolean, todos: string[]): string[] {
|
|
266
|
+
const wrap = (inner: string): string => wrapValue(fanout, inner);
|
|
267
|
+
|
|
268
|
+
if (probe.kind === 'injected') {
|
|
269
|
+
// Without a harvested factory, the double is not a runner mock: `.mockImplementation`
|
|
270
|
+
// would not exist on it. The probe has then already been placed at wiring, in `only()`.
|
|
271
|
+
if (!mocked) {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
todos.push(valueTodo(probe.property, probe.method));
|
|
275
|
+
return [
|
|
276
|
+
` // Derived from the scan: this is the call found in the .map(). The probe ${fanout ? 'wraps' : 'counts'} without`,
|
|
277
|
+
` // deciding the returned value — it is up to the TODO above to provide it.`,
|
|
278
|
+
` ${probe.property}.${probe.method}.mockImplementation(async () =>`,
|
|
279
|
+
` ${wrap(`missing('value returned by ${probe.property}.${probe.method}()')`)},`,
|
|
280
|
+
` );`,
|
|
281
|
+
'',
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
if (probe.kind === 'own') {
|
|
285
|
+
return [
|
|
286
|
+
` // Derived from the scan: this is the call the .map() returns. The probe wraps the`,
|
|
287
|
+
` // real method, it does not replace it — what is measured stays the real code.`,
|
|
288
|
+
fanout
|
|
289
|
+
? ` around(subject, '${probe.method}', (call) => gauge.track(async () => call()));`
|
|
290
|
+
: ` around(subject, '${probe.method}', (call) => {\n counter.hit();\n return call();\n });`,
|
|
291
|
+
'',
|
|
292
|
+
];
|
|
293
|
+
}
|
|
294
|
+
if (probe.kind === 'parameter') {
|
|
295
|
+
const index = subject.entryParameters.findIndex(({ name }) => name === probe.parameter);
|
|
296
|
+
const type = index === -1 ? 'unknown' : `Parameters<${signature(subject)}>[${index}]`;
|
|
297
|
+
todos.push(valueTodo(probe.parameter, probe.method));
|
|
298
|
+
return [
|
|
299
|
+
` // The double carries only the probed member: any other access throws, naming itself.`,
|
|
300
|
+
` // An incomplete draft therefore cannot measure green by accident.`,
|
|
301
|
+
` const ${probe.parameter} = only<${type}>('${probe.parameter}', '${probe.method}', () =>`,
|
|
302
|
+
` ${wrap(`missing('value returned by ${probe.parameter}.${probe.method}()')`)},`,
|
|
303
|
+
` );`,
|
|
304
|
+
'',
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The collection: first trace it back, then feed it where it is born.
|
|
312
|
+
*
|
|
313
|
+
* Three origins, three wirings, and a single case with no answer — the one where tracing
|
|
314
|
+
* back did not succeed. It stays written out in full rather than filled in by guesswork:
|
|
315
|
+
* a size that does not reach the subject makes the measurement flat, and the rule lies.
|
|
316
|
+
*/
|
|
317
|
+
function renderCollection(
|
|
318
|
+
site: DraftSite,
|
|
319
|
+
harvest: Harvest | undefined,
|
|
320
|
+
probe: Probe,
|
|
321
|
+
fanout: boolean,
|
|
322
|
+
hint: string,
|
|
323
|
+
todos: string[],
|
|
324
|
+
): { block: string; feeding: string[] } {
|
|
325
|
+
// A probe carried by the item forces its own factory: the spec's would count nothing.
|
|
326
|
+
if (probe.kind === 'item') {
|
|
327
|
+
todos.push(`provide the value each item returns from ${probe.method}().`);
|
|
328
|
+
const inner = fanout
|
|
329
|
+
? `gauge.track(async () => missing('value returned by item.${probe.method}()'))`
|
|
330
|
+
: `{\n counter.hit();\n return missing('value returned by item.${probe.method}()');\n }`;
|
|
331
|
+
const block = `/** The probe is carried by the item: the collection is ours, the only safe place for it. */
|
|
332
|
+
function collectionOf(size: number) {
|
|
333
|
+
return Array.from({ length: size }, () => ({
|
|
334
|
+
${probe.method}: () => ${inner},
|
|
335
|
+
}));
|
|
336
|
+
}`;
|
|
337
|
+
return { block, feeding: feed(site, harvest, todos) };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const origin = site.origin;
|
|
341
|
+
const mock = (harvest?.mocks ?? []).find(
|
|
342
|
+
(candidate) =>
|
|
343
|
+
origin !== undefined &&
|
|
344
|
+
origin.kind === 'dependency' &&
|
|
345
|
+
candidate.property === origin.property &&
|
|
346
|
+
candidate.method === origin.method &&
|
|
347
|
+
candidate.arrayProperty === origin.path[0],
|
|
348
|
+
);
|
|
349
|
+
if (mock === undefined) {
|
|
350
|
+
const collectionTodo = `build a collection of \`size\` items.${hint}`;
|
|
351
|
+
todos.push(collectionTodo);
|
|
352
|
+
// When the collection is an argument of the entry point, its type is written in the
|
|
353
|
+
// signature. Leaving it as `unknown[]` forced fixing the hole *and* its type.
|
|
354
|
+
const returns = origin?.kind === 'argument' ? `Parameters<${signature(site.subject)}>[${origin.index}]` : 'unknown[]';
|
|
355
|
+
const block = `/** TODO speccov: ${collectionTodo} */
|
|
356
|
+
function collectionOf(size: number): ${returns} {
|
|
357
|
+
throw new Error(\`TODO speccov: build a collection of \${size} items.\`);
|
|
358
|
+
}`;
|
|
359
|
+
return { block, feeding: feed(site, harvest, todos) };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const itemBuilder = (harvest?.localBuilders ?? []).find(({ name }) => mock.itemCall.startsWith(`${name}(`));
|
|
363
|
+
const block = `${itemBuilder === undefined ? '' : `/** Copied from ${basename(harvest?.specFile ?? '', '.ts')}: the factory the spec already feeds. */\n${itemBuilder.source}\n\n`}/** The same item repeated: the size varies, the content does not — the measurement depends on it alone. */
|
|
364
|
+
function collectionOf(size: number) {
|
|
365
|
+
return Array.from({ length: size }, () => ${mock.itemCall});
|
|
366
|
+
}`;
|
|
367
|
+
return { block, feeding: feed(site, harvest, todos, mock) };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Where the items enter the measured code. */
|
|
371
|
+
function feed(site: DraftSite, harvest: Harvest | undefined, todos: string[], mock?: Harvest['mocks'][number]): string[] {
|
|
372
|
+
const origin = site.origin;
|
|
373
|
+
const items = ' const items = collectionOf(size);';
|
|
374
|
+
|
|
375
|
+
if (origin?.kind === 'argument') {
|
|
376
|
+
// The safest case: the collection is an argument of the entry point, so it is the
|
|
377
|
+
// draft that passes it. Nothing to double, nothing to trace back.
|
|
378
|
+
return [
|
|
379
|
+
` // "${origin.name}" is argument ${origin.index + 1} of the entry point: the size enters there directly.`,
|
|
380
|
+
items,
|
|
381
|
+
'',
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
if (origin?.kind === 'dependency' && mock !== undefined) {
|
|
385
|
+
const rebuilt = [
|
|
386
|
+
`${mock.arrayProperty}: items`,
|
|
387
|
+
...mock.others.map(({ name, text, wasCount }) => `${name}: ${wasCount ? 'items.length' : text}`),
|
|
388
|
+
].join(', ');
|
|
389
|
+
return [
|
|
390
|
+
` // Traced back from the site to ${origin.property}.${origin.method}(): that is where the collection comes from.`,
|
|
391
|
+
items,
|
|
392
|
+
` ${origin.property}.${origin.method}.mockResolvedValue({ ${rebuilt} });`,
|
|
393
|
+
'',
|
|
394
|
+
];
|
|
395
|
+
}
|
|
396
|
+
todos.push('wire this collection onto the dependency that produces it.');
|
|
397
|
+
void harvest;
|
|
398
|
+
return [
|
|
399
|
+
items,
|
|
400
|
+
` missing(\`wire these \${items.length} items onto the dependency that produces the collection\`);`,
|
|
401
|
+
'',
|
|
402
|
+
];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The entry point arguments, one by one.
|
|
407
|
+
*
|
|
408
|
+
* Each has a derived role — the collection, the probed double — or stays a hole. A hole
|
|
409
|
+
* is a function that throws, and its type comes from the signature: the compiler will say
|
|
410
|
+
* what to provide, better than a sentence would.
|
|
411
|
+
*/
|
|
412
|
+
function renderEntryArguments(
|
|
413
|
+
site: DraftSite,
|
|
414
|
+
harvest: Harvest | undefined,
|
|
415
|
+
todos: string[],
|
|
416
|
+
hint: string,
|
|
417
|
+
): { args: string[]; blocks: string[] } {
|
|
418
|
+
const { subject, probe, origin } = site;
|
|
419
|
+
const args: string[] = [];
|
|
420
|
+
const blocks: string[] = [];
|
|
421
|
+
const taken = new Set(RESERVED);
|
|
422
|
+
|
|
423
|
+
subject.entryParameters.forEach((parameter, index) => {
|
|
424
|
+
if (origin?.kind === 'argument' && origin.index === index) {
|
|
425
|
+
args.push('items');
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (probe.kind === 'parameter' && parameter.name === probe.parameter) {
|
|
429
|
+
args.push(probe.parameter);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const name = stubName(subject, parameter, index, taken);
|
|
433
|
+
taken.add(name);
|
|
434
|
+
const wanted = `Parameters<${signature(subject)}>[${index}]`;
|
|
435
|
+
const copied = (harvest?.localBuilders ?? []).find(({ returns }) => returns === wanted);
|
|
436
|
+
if (copied === undefined) {
|
|
437
|
+
const what = `provide ${describeArgument(subject, parameter, index)}.${hint}`;
|
|
438
|
+
todos.push(what);
|
|
439
|
+
blocks.push(`/** TODO speccov: ${what} */
|
|
440
|
+
function ${name}(): ${wanted} {
|
|
441
|
+
throw new Error('TODO speccov: provide ${describeArgument(subject, parameter, index)}.');
|
|
442
|
+
}`);
|
|
443
|
+
} else {
|
|
444
|
+
// The factory is **copied** from the neighbouring spec, and only when its return
|
|
445
|
+
// type is written and equal to the hole's. The name decides nothing: two
|
|
446
|
+
// factories can share a name and return something else.
|
|
447
|
+
blocks.push(`/** Copied from ${basename(harvest?.specFile ?? '', '.ts')}: same return type, verbatim. */
|
|
448
|
+
${copied.source.replace(copied.name, name)}`);
|
|
449
|
+
}
|
|
450
|
+
args.push(`${name}()`);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
return { args, blocks };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function describeArgument(subject: Subject, parameter: Parameter, index: number): string {
|
|
457
|
+
const target = subject.kind === 'class' ? `${subject.className}.${subject.methodName}()` : `${subject.functionName}()`;
|
|
458
|
+
return parameter.name === ''
|
|
459
|
+
? `argument ${index + 1} of ${target}`
|
|
460
|
+
: subject.entryParameters.length === 1
|
|
461
|
+
? `a valid command for ${target}`
|
|
462
|
+
: `a value for "${parameter.name}: ${parameter.type}" of ${target}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function stubName(subject: Subject, parameter: Parameter, index: number, taken: ReadonlySet<string>): string {
|
|
466
|
+
if (subject.entryParameters.length === 1) {
|
|
467
|
+
return 'command';
|
|
468
|
+
}
|
|
469
|
+
return parameter.name !== '' && !taken.has(parameter.name) ? `${parameter.name}Argument` : `argument${index + 1}`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The decision no probe replaces: what the counted call must return.
|
|
474
|
+
*
|
|
475
|
+
* Placing the probe does not say which value it returns, and the measured code consumes that
|
|
476
|
+
* value. The decision list is the contract with whoever fills it — often an agent: a hole the
|
|
477
|
+
* list keeps quiet about becomes an unannounced `throw`, so a loop, or worse a `missing()`
|
|
478
|
+
* deleted to "repair" things. Hence the invariant: one `missing(…)` written, one line.
|
|
479
|
+
*/
|
|
480
|
+
function valueTodo(carrier: string, method: string): string {
|
|
481
|
+
return `provide the value ${carrier}.${method}() must return.`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Wrap a value the same way everywhere: the probe does not change with the place. */
|
|
485
|
+
function wrapValue(fanout: boolean, inner: string): string {
|
|
486
|
+
return fanout
|
|
487
|
+
? `gauge.track(async () => ${inner})`
|
|
488
|
+
: `(() => {\n counter.hit();\n return ${inner};\n })()`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function signature(subject: Subject): string {
|
|
492
|
+
return subject.kind === 'class' ? `${subject.className}['${subject.methodName}']` : `typeof ${subject.functionName}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function label(subject: Subject): string {
|
|
496
|
+
return subject.kind === 'class' ? `${subject.className}.${subject.methodName}` : subject.functionName;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function short(probe: Probe): string {
|
|
500
|
+
switch (probe.kind) {
|
|
501
|
+
case 'injected':
|
|
502
|
+
return `${probe.property}.${probe.method}`;
|
|
503
|
+
case 'parameter':
|
|
504
|
+
return `${probe.parameter}.${probe.method}`;
|
|
505
|
+
case 'item':
|
|
506
|
+
return `item.${probe.method}`;
|
|
507
|
+
default:
|
|
508
|
+
return probe.method;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function describe(probe: Probe): string {
|
|
513
|
+
switch (probe.kind) {
|
|
514
|
+
case 'injected':
|
|
515
|
+
return `this.${probe.property}.${probe.method}()`;
|
|
516
|
+
case 'parameter':
|
|
517
|
+
return `${probe.parameter}.${probe.method}()`;
|
|
518
|
+
case 'item':
|
|
519
|
+
return `item.${probe.method}()`;
|
|
520
|
+
default:
|
|
521
|
+
return `this.${probe.method}()`;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** `buildMockOrganizations` covers `OrganizationRepositoryPort`: we compare the roots. */
|
|
526
|
+
function matches(builderName: string, type: string): boolean {
|
|
527
|
+
const root = builderName.replace(/^buildMock/u, '').toLowerCase();
|
|
528
|
+
const target = type.replace(/(RepositoryPort|Port|Service)$/u, '').toLowerCase();
|
|
529
|
+
return root.startsWith(target) || target.startsWith(root);
|
|
530
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { draft } from './index';
|
|
3
|
+
import type { Site } from '../scan/shapes';
|
|
4
|
+
|
|
5
|
+
export type Drafted = {
|
|
6
|
+
readonly file: string;
|
|
7
|
+
readonly line: number;
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly todos: readonly string[];
|
|
10
|
+
readonly content: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type SweepResult = {
|
|
14
|
+
readonly drafted: readonly Drafted[];
|
|
15
|
+
/** The refusals, by reason: the map of what `draft` cannot read yet. */
|
|
16
|
+
readonly refusedBy: ReadonlyMap<string, number>;
|
|
17
|
+
readonly tried: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Only these shapes go through a `.map()`: the others have no site to wire. */
|
|
21
|
+
const DRAFTABLE = new Set(['unbounded-fanout', 'async-per-item']);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Runs `draft` on every site `scan` found.
|
|
25
|
+
*
|
|
26
|
+
* The missing link: `scan` points, `draft` writes, and until now it was a human copying a
|
|
27
|
+
* `file:line` from one to the other. An agent will not do it any more than a tired human
|
|
28
|
+
* will.
|
|
29
|
+
*
|
|
30
|
+
* Refusals are **counted by reason**, not thrown away. It is the only honest measure of
|
|
31
|
+
* what the bridge covers: across five third-party repos, it was 0 out of 350.
|
|
32
|
+
*/
|
|
33
|
+
export async function sweep(root: string, sites: readonly Site[]): Promise<SweepResult> {
|
|
34
|
+
const drafted: Drafted[] = [];
|
|
35
|
+
const refusedBy = new Map<string, number>();
|
|
36
|
+
let tried = 0;
|
|
37
|
+
|
|
38
|
+
for (const site of sites) {
|
|
39
|
+
if (!DRAFTABLE.has(site.kind)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
tried += 1;
|
|
43
|
+
const result = await draft(resolve(root, site.file), site.line);
|
|
44
|
+
if (result.kind === 'refused') {
|
|
45
|
+
const reason = family(result.reason);
|
|
46
|
+
refusedBy.set(reason, (refusedBy.get(reason) ?? 0) + 1);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
drafted.push({
|
|
50
|
+
file: site.file,
|
|
51
|
+
line: site.line,
|
|
52
|
+
path: result.draft.path,
|
|
53
|
+
todos: result.draft.todos,
|
|
54
|
+
content: result.draft.content,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// Two shapes can land on the same `.map()` — the `Promise.all` and the per-item call —
|
|
58
|
+
// and then produce the same file. We keep the most complete draft, never two concurrent
|
|
59
|
+
// writes to the same path.
|
|
60
|
+
const best = new Map<string, Drafted>();
|
|
61
|
+
for (const entry of drafted) {
|
|
62
|
+
const kept = best.get(entry.path);
|
|
63
|
+
if (kept === undefined || entry.todos.length < kept.todos.length) {
|
|
64
|
+
best.set(entry.path, entry);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { drafted: [...best.values()], refusedBy, tried };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The reason, without the file name nor the class name.
|
|
72
|
+
*
|
|
73
|
+
* Three hundred all-distinct refusals do not get read; grouped by family, they say where to
|
|
74
|
+
* put the effort. It is the same gesture as the "Not measured" section of the report.
|
|
75
|
+
*/
|
|
76
|
+
function family(reason: string): string {
|
|
77
|
+
return reason
|
|
78
|
+
.replace(/"[^"]*"/gu, '…')
|
|
79
|
+
.replace(/at line \d+ of \S+/u, 'at this line')
|
|
80
|
+
.replace(/^\S+ could not be/u, 'the file could not be');
|
|
81
|
+
}
|