@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,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
+ }