pipework 0.268.0 → 0.270.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/REFERENCE.md +48 -0
  3. package/dist/cli/commands/check-steps.d.ts +34 -0
  4. package/dist/cli/commands/check-steps.d.ts.map +1 -0
  5. package/dist/cli/commands/check-steps.js +60 -0
  6. package/dist/cli/commands/check-steps.js.map +1 -0
  7. package/dist/cli/commands/check.d.ts +43 -1
  8. package/dist/cli/commands/check.d.ts.map +1 -1
  9. package/dist/cli/commands/check.js +232 -133
  10. package/dist/cli/commands/check.js.map +1 -1
  11. package/dist/cli/commands/lint.d.ts.map +1 -1
  12. package/dist/cli/commands/lint.js +36 -1
  13. package/dist/cli/commands/lint.js.map +1 -1
  14. package/dist/cli/index.d.ts.map +1 -1
  15. package/dist/cli/index.js +55 -2
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/lint/doctrine.d.ts +2 -0
  18. package/dist/lint/doctrine.d.ts.map +1 -1
  19. package/dist/lint/doctrine.js +3 -1
  20. package/dist/lint/doctrine.js.map +1 -1
  21. package/dist/request/di/builder.d.ts +1 -0
  22. package/dist/request/di/builder.d.ts.map +1 -1
  23. package/dist/request/di/builder.js +1 -0
  24. package/dist/request/di/builder.js.map +1 -1
  25. package/dist/request/di/types.d.ts +2 -0
  26. package/dist/request/di/types.d.ts.map +1 -1
  27. package/dist/request/http/namespace.d.ts +1 -1
  28. package/dist/request/http/namespace.d.ts.map +1 -1
  29. package/dist/request/http/namespace.js +1 -1
  30. package/dist/request/http/namespace.js.map +1 -1
  31. package/dist/request/http/server.d.ts.map +1 -1
  32. package/dist/request/http/server.js +4 -2
  33. package/dist/request/http/server.js.map +1 -1
  34. package/dist/trace/cardinality/check.d.ts +5 -1
  35. package/dist/trace/cardinality/check.d.ts.map +1 -1
  36. package/dist/trace/cardinality/check.js +3 -1
  37. package/dist/trace/cardinality/check.js.map +1 -1
  38. package/dist/trace/check/coverage-check.d.ts +2 -0
  39. package/dist/trace/check/coverage-check.d.ts.map +1 -1
  40. package/dist/trace/check/coverage-check.js +3 -1
  41. package/dist/trace/check/coverage-check.js.map +1 -1
  42. package/dist/trace/check/flow-check.d.ts +2 -0
  43. package/dist/trace/check/flow-check.d.ts.map +1 -1
  44. package/dist/trace/check/flow-check.js +3 -1
  45. package/dist/trace/check/flow-check.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/cli/commands/check-steps.ts +98 -0
  48. package/src/cli/commands/check.ts +283 -147
  49. package/src/cli/commands/lint.ts +34 -1
  50. package/src/cli/index.ts +56 -2
  51. package/src/lint/doctrine.ts +5 -1
  52. package/src/request/di/builder.ts +2 -1
  53. package/src/request/di/types.ts +2 -0
  54. package/src/request/http/namespace.ts +1 -1
  55. package/src/request/http/server.ts +4 -2
  56. package/src/trace/cardinality/check.ts +9 -2
  57. package/src/trace/check/coverage-check.ts +5 -1
  58. package/src/trace/check/flow-check.ts +5 -1
@@ -2,7 +2,7 @@ import { execSync, execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
4
4
  import { createHash } from 'node:crypto';
5
- import { join, dirname, isAbsolute, resolve } from 'node:path';
5
+ import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { createRequire } from 'node:module';
8
8
  const execFileAsync = promisify(execFile);
@@ -22,6 +22,10 @@ import { expandGlob, resolveModules } from '../../workspace/discover.js';
22
22
  import { filesMatchingGlobs, globRoot, lintIncludeGlobs } from '../../lint/include.js';
23
23
  import { StartupValidationError } from '../../core/validation/types.js';
24
24
  import { shardArg, shardModules } from './shard.js';
25
+ import { record, emitJson } from './check-steps.js';
26
+ function stepRecord(step, status, violations = [], scannedFiles) {
27
+ return record(step, pipeworkVersion(), status, violations, scannedFiles);
28
+ }
25
29
  // #322: discoverInstance evaluates pipework.config.ts — a dynamic import plus a
26
30
  // full module evaluation. A single `check` run needs it from four call sites;
27
31
  // memoizing collapses that to one load. The cache is reset at the start of
@@ -35,21 +39,39 @@ async function discoverInstanceCached(cwd) {
35
39
  }
36
40
  return loading;
37
41
  }
38
- export async function check(cwd, scope, shard) {
42
+ export async function check(cwd, scope, shard, options = {}) {
39
43
  instanceCache = new Map();
40
44
  let failed = false;
41
- const checksumOk = verifyChecksums(cwd);
42
- if (!checksumOk) {
43
- failed = true;
44
- }
45
- const vitestOk = await verifyVitestConfig(cwd);
46
- if (!vitestOk) {
47
- failed = true;
48
- }
49
- const eslintOk = verifyEslintConfig(cwd);
50
- if (!eslintOk) {
51
- failed = true;
52
- }
45
+ const selected = options.select ?? (() => true);
46
+ const records = [];
47
+ // A deselected step is named in the human output; a step whose precondition is
48
+ // absent is skipped as it always was, without a line.
49
+ const skip = (step, label, reason) => {
50
+ if (!selected(step))
51
+ warn(`${label} — skipped (not selected)`);
52
+ records.push(record(step, pipeworkVersion(), 'skipped', [], undefined, selected(step) ? reason : 'not selected'));
53
+ };
54
+ // A step that prints its own lines and answers pass/fail.
55
+ const plain = async (step, label, fn) => {
56
+ if (!selected(step))
57
+ return skip(step, label, '');
58
+ const ok = await fn();
59
+ records.push(stepRecord(step, ok ? 'passed' : 'failed'));
60
+ if (!ok)
61
+ failed = true;
62
+ };
63
+ // A step that prints its own lines and returns its record.
64
+ const rich = async (step, label, fn) => {
65
+ if (!selected(step))
66
+ return skip(step, label, '');
67
+ const result = await fn();
68
+ records.push(result);
69
+ if (result.status === 'failed')
70
+ failed = true;
71
+ };
72
+ await plain('checksums', 'Workspace checksums', () => verifyChecksums(cwd));
73
+ await plain('vitest-config', 'Vitest config', () => verifyVitestConfig(cwd));
74
+ await plain('eslint-config', 'Eslint config', () => verifyEslintConfig(cwd));
53
75
  const { paths: modulePaths, all: fullScope, configError, basis } = await resolveModulePaths(cwd, scope);
54
76
  // #335: a pipework.config.ts that exists but cannot be loaded used to be
55
77
  // swallowed — module scoping silently degraded to filesystem discovery, which
@@ -58,84 +80,132 @@ export async function check(cwd, scope, shard) {
58
80
  // 0 with "All checks passed". A step that cannot run is a failed step: the
59
81
  // load failure is reported here and fails the run. The remaining steps still
60
82
  // execute so one run surfaces everything it can, but none of them can green it.
61
- if (configError !== undefined) {
83
+ if (!selected('config-load')) {
84
+ skip('config-load', 'Config load', '');
85
+ }
86
+ else if (configError !== undefined) {
62
87
  info('Config load');
63
88
  logError('Config load — pipework.config.ts could not be loaded. Config-derived steps ran degraded or not at all.');
64
89
  for (const line of configError.message.split('\n')) {
65
90
  process.stderr.write(` ${line}\n`);
66
91
  }
92
+ records.push(stepRecord('config-load', 'failed', [{ rule: 'config/load', file: CONFIG_FILE, line: 0, message: configError.message }]));
67
93
  failed = true;
68
94
  }
95
+ else {
96
+ records.push(stepRecord('config-load', 'passed'));
97
+ }
69
98
  if (modulePaths.length > 0) {
70
99
  const sourceFiles = findSourceFiles(cwd, modulePaths);
71
- const doctrineOk = runDoctrineValidation(cwd, sourceFiles);
72
- if (!doctrineOk) {
73
- failed = true;
74
- }
100
+ await rich('doctrine', 'Doctrine validation', () => doctrineStep(cwd, sourceFiles));
75
101
  // Phase 4b: the flow and cardinality checks are hard errors. Consumers
76
102
  // upgrading from an earlier release run `pipework codemod` once to fill
77
103
  // missing `@downstream` cardinalities; anything the codemod cannot
78
104
  // resolve (indirection, non-trivial form, method flow steps, orphans)
79
105
  // must be fixed by hand before the build will pass.
80
- if (!runFlowCheck(cwd, sourceFiles)) {
81
- failed = true;
82
- }
83
- const configuredStructuralMax = await loadConfiguredStructuralMax(cwd);
84
- if (!runCardinalityCheck(cwd, sourceFiles, configuredStructuralMax, fullScope)) {
85
- failed = true;
86
- }
106
+ await rich('flow', 'Flow annotations', () => flowStep(cwd, sourceFiles));
107
+ await rich('cardinality', 'Flow cardinality', async () => cardinalityStep(cwd, sourceFiles, await loadConfiguredStructuralMax(cwd), fullScope));
87
108
  // Phase 6f: Tier-2 coverage ships as warning. Surfaces structural gaps —
88
109
  // I/O outside any flow_step, more than one I/O in a single flow_step —
89
110
  // without failing the build. Phase 7a flips it to error once consumer
90
111
  // corpora have migrated to the new write surface. Consumers extend the
91
112
  // I/O call-target registry through `trace.io` in pipework.config.ts (Q9).
92
- const consumerIoPatterns = await loadConsumerIoPatterns(cwd);
93
- runCoverageCheckWarning(cwd, sourceFiles, consumerIoPatterns);
94
- const lintOk = await runLint(cwd, modulePaths, shard);
95
- if (!lintOk) {
96
- failed = true;
113
+ await rich('coverage', 'Flow coverage', async () => coverageStep(cwd, sourceFiles, await loadConsumerIoPatterns(cwd)));
114
+ await plain('lint', 'Lint', () => runLint(cwd, modulePaths, shard));
115
+ await plain('typecheck', 'Type check', () => runTypeCheck(cwd, modulePaths, shard));
116
+ }
117
+ else {
118
+ for (const [step, label] of [
119
+ ['doctrine', 'Doctrine validation'], ['flow', 'Flow annotations'], ['cardinality', 'Flow cardinality'],
120
+ ['coverage', 'Flow coverage'], ['lint', 'Lint'], ['typecheck', 'Type check'],
121
+ ]) {
122
+ skip(step, label, 'no modules resolved');
97
123
  }
98
- const typeCheckOk = await runTypeCheck(cwd, modulePaths, shard);
99
- if (!typeCheckOk) {
100
- failed = true;
124
+ if (scope !== undefined && scope.kind === 'diff') {
125
+ // The basis says WHY the set is empty — a green here must state the scope
126
+ // it rested on, or an empty basis reads as "everything passed".
127
+ success(`No affected modules — per-module checks skipped (basis: ${basis ?? 'unknown'})`);
128
+ }
129
+ else {
130
+ // Not a diff scope, so this is not "nothing changed" — no module could be
131
+ // resolved at all. Every per-module step was skipped; say so rather than
132
+ // letting their absence read as a clean pass (#335).
133
+ warn('No modules resolved — lint, type check, doctrine and flow checks did NOT run');
101
134
  }
102
135
  }
103
- else if (scope !== undefined && scope.kind === 'diff') {
104
- // The basis says WHY the set is empty — a green here must state the scope
105
- // it rested on, or an empty basis reads as "everything passed".
106
- success(`No affected modules — per-module checks skipped (basis: ${basis ?? 'unknown'})`);
136
+ if (configError === undefined && findConfig(cwd) !== null) {
137
+ await plain('startup', 'Startup checks', () => runStartupChecks(cwd));
107
138
  }
108
139
  else {
109
- // Not a diff scope, so this is not "nothing changed" — no module could be
110
- // resolved at all. Every per-module step was skipped; say so rather than
111
- // letting their absence read as a clean pass (#335).
112
- warn('No modules resolved — lint, type check, doctrine and flow checks did NOT run');
113
- }
114
- if (configError === undefined && findConfig(cwd) !== null) {
115
- if (!(await runStartupChecks(cwd))) {
116
- failed = true;
117
- }
140
+ skip('startup', 'Startup checks', 'no loadable pipework.config.ts');
118
141
  }
119
142
  const boundaryScript = join(cwd, 'scripts', 'check-boundaries.js');
120
143
  if (existsSync(boundaryScript)) {
121
- if (!runStep('Boundaries', 'node scripts/check-boundaries.js', cwd)) {
122
- failed = true;
123
- }
124
- }
125
- const depCheckOk = runDependencyCheck(cwd);
126
- if (!depCheckOk) {
127
- failed = true;
144
+ await plain('boundaries', 'Boundaries', () => runStep('Boundaries', 'node scripts/check-boundaries.js', cwd));
128
145
  }
129
- const copiesOk = runPipeworkCopiesCheck(cwd);
130
- if (!copiesOk) {
131
- failed = true;
146
+ else {
147
+ skip('boundaries', 'Boundaries', 'no scripts/check-boundaries.js');
132
148
  }
149
+ await plain('dependencies', 'Dependency boundaries', () => runDependencyCheck(cwd));
150
+ await plain('install', 'pipework install integrity', () => runPipeworkCopiesCheck(cwd));
151
+ if (options.format === 'json')
152
+ emitJson(records, options.outputFile);
133
153
  if (failed)
134
154
  process.exit(1);
135
- success('All checks passed');
155
+ const deselected = records.filter(r => r.reason === 'not selected').map(r => r.step);
156
+ success(deselected.length === 0 ? 'All checks passed' : `Selected checks passed (not run: ${deselected.join(', ')})`);
157
+ }
158
+ /** `pipework doctrine|flow|cardinality|startup`: one step alone. Returns the exit code. */
159
+ export async function stepCommand(cwd, step, options = {}) {
160
+ instanceCache = new Map();
161
+ const files = options.files ?? [];
162
+ let result;
163
+ if (step === 'startup') {
164
+ if (files.length > 0)
165
+ throw new Error('startup takes no files.');
166
+ result = await startupStep(cwd);
167
+ }
168
+ else {
169
+ let sourceFiles;
170
+ let fullScope = false;
171
+ let configError;
172
+ if (files.length > 0) {
173
+ sourceFiles = files.map(f => {
174
+ const rel = relative(cwd, resolve(cwd, f));
175
+ if (rel.startsWith('..') || isAbsolute(rel))
176
+ throw new Error(`${f} is outside ${cwd}.`);
177
+ if (!existsSync(join(cwd, rel)))
178
+ throw new Error(`${f} does not exist.`);
179
+ return rel.split(sep).join('/');
180
+ });
181
+ }
182
+ else {
183
+ const resolved = await resolveModulePaths(cwd, options.scope);
184
+ configError = resolved.configError;
185
+ fullScope = resolved.all;
186
+ sourceFiles = findSourceFiles(cwd, resolved.paths);
187
+ }
188
+ if (configError !== undefined) {
189
+ logError('Config load — pipework.config.ts could not be loaded.');
190
+ process.stderr.write(` ${configError.message}\n`);
191
+ result = stepRecord(step, 'failed', [{ rule: 'config/load', file: CONFIG_FILE, line: 0, message: configError.message }]);
192
+ }
193
+ else if (step === 'doctrine') {
194
+ result = doctrineStep(cwd, sourceFiles);
195
+ }
196
+ else if (step === 'flow') {
197
+ result = flowStep(cwd, sourceFiles);
198
+ }
199
+ else {
200
+ result = cardinalityStep(cwd, sourceFiles, await loadConfiguredStructuralMax(cwd), fullScope);
201
+ }
202
+ }
203
+ if (options.format === 'json')
204
+ emitJson(result, options.outputFile);
205
+ return result.status === 'failed' ? 1 : 0;
136
206
  }
137
207
  /** The startup checks that need no database — what `serve` would refuse before connecting. */
138
- export async function runStartupChecks(cwd) {
208
+ export async function startupStep(cwd) {
139
209
  info('Startup checks');
140
210
  let doors = [];
141
211
  let roles = [];
@@ -153,12 +223,14 @@ export async function runStartupChecks(cwd) {
153
223
  for (const issue of err.issues) {
154
224
  process.stderr.write(` [${issue.phase}] ${issue.message}\n`);
155
225
  }
226
+ return stepRecord('startup', 'failed', err.issues.map(issue => ({
227
+ rule: `startup/${issue.phase}`, file: CONFIG_FILE, line: 0, message: issue.message,
228
+ })));
156
229
  }
157
- else {
158
- logError('Startup checks — could not run');
159
- process.stderr.write(` ${err instanceof Error ? err.message : String(err)}\n`);
160
- }
161
- return false;
230
+ logError('Startup checks — could not run');
231
+ const message = err instanceof Error ? err.message : String(err);
232
+ process.stderr.write(` ${message}\n`);
233
+ return stepRecord('startup', 'failed', [{ rule: 'startup/could-not-run', file: CONFIG_FILE, line: 0, message }]);
162
234
  }
163
235
  success('Startup checks');
164
236
  for (const line of roles)
@@ -168,7 +240,10 @@ export async function runStartupChecks(cwd) {
168
240
  for (const line of doors)
169
241
  info(` ${line}`);
170
242
  }
171
- return true;
243
+ return stepRecord('startup', 'passed');
244
+ }
245
+ export async function runStartupChecks(cwd) {
246
+ return (await startupStep(cwd)).status === 'passed';
172
247
  }
173
248
  /** One line per platform-mode database: the role a running manifold connects as, and the only callers that may open the owner. */
174
249
  export function describeRuntimeRoles(databases) {
@@ -435,18 +510,8 @@ function findStaleVitestConfigs(cwd) {
435
510
  }
436
511
  return stale.sort();
437
512
  }
438
- function runDoctrineValidation(cwd, sourceFiles) {
439
- info('Doctrine validation');
440
- if (sourceFiles.length === 0) {
441
- success('Doctrine validation (no source files)');
442
- return true;
443
- }
444
- const result = validateDoctrine(cwd, sourceFiles);
445
- if (result.violations.length === 0) {
446
- success(`Doctrine validation (${String(result.scanned)} files scanned)`);
447
- return true;
448
- }
449
- const grouped = groupByRule(result.violations);
513
+ function printGrouped(violations) {
514
+ const grouped = groupByRule(violations);
450
515
  for (const [rule, items] of grouped) {
451
516
  logError(`${rule} (${String(items.length)} violation${items.length === 1 ? '' : 's'})`);
452
517
  for (const v of items.slice(0, 5)) {
@@ -456,36 +521,44 @@ function runDoctrineValidation(cwd, sourceFiles) {
456
521
  warn(` ... and ${String(items.length - 5)} more`);
457
522
  }
458
523
  }
459
- return false;
460
524
  }
461
- export function runFlowCheck(cwd, sourceFiles) {
525
+ export function doctrineStep(cwd, sourceFiles) {
526
+ info('Doctrine validation');
527
+ if (sourceFiles.length === 0) {
528
+ success('Doctrine validation (no source files)');
529
+ return stepRecord('doctrine', 'passed', [], []);
530
+ }
531
+ const result = validateDoctrine(cwd, sourceFiles);
532
+ if (result.violations.length === 0) {
533
+ success(`Doctrine validation (${String(result.scanned)} files scanned)`);
534
+ return stepRecord('doctrine', 'passed', [], result.scannedFiles);
535
+ }
536
+ printGrouped(result.violations);
537
+ return stepRecord('doctrine', 'failed', result.violations, result.scannedFiles);
538
+ }
539
+ export function flowStep(cwd, sourceFiles) {
462
540
  info('Flow annotations');
463
541
  if (sourceFiles.length === 0) {
464
542
  success('Flow annotations (no source files)');
465
- return true;
543
+ return stepRecord('flow', 'passed', [], []);
466
544
  }
467
545
  const result = checkFlowAnnotations(cwd, sourceFiles);
468
546
  if (result.violations.length === 0) {
469
547
  success(`Flow annotations (${String(result.scanned)} files scanned)`);
470
- return true;
471
- }
472
- const grouped = groupByRule(result.violations);
473
- for (const [rule, items] of grouped) {
474
- logError(`${rule} (${String(items.length)} violation${items.length === 1 ? '' : 's'})`);
475
- for (const v of items.slice(0, 5)) {
476
- warn(` ${v.file}:${String(v.line)} — ${v.message}`);
477
- }
478
- if (items.length > 5) {
479
- warn(` ... and ${String(items.length - 5)} more`);
480
- }
548
+ return stepRecord('flow', 'passed', [], result.scannedFiles);
481
549
  }
482
- return false;
550
+ printGrouped(result.violations);
551
+ return stepRecord('flow', 'failed', result.violations, result.scannedFiles);
483
552
  }
484
- export function runCardinalityCheck(cwd, sourceFiles, configuredStructuralMax, fullScope = true) {
553
+ export function runFlowCheck(cwd, sourceFiles) {
554
+ return flowStep(cwd, sourceFiles).status === 'passed';
555
+ }
556
+ const CONFIG_FILE = 'pipework.config.ts';
557
+ export function cardinalityStep(cwd, sourceFiles, configuredStructuralMax, fullScope = true) {
485
558
  info('Flow cardinality');
486
559
  if (sourceFiles.length === 0) {
487
560
  success('Flow cardinality (no source files)');
488
- return true;
561
+ return stepRecord('cardinality', 'passed', [], []);
489
562
  }
490
563
  const result = checkCardinality(cwd, sourceFiles);
491
564
  // Verify the configured cap against what the analyzer just derived (Q7).
@@ -504,54 +577,57 @@ export function runCardinalityCheck(cwd, sourceFiles, configuredStructuralMax, f
504
577
  // derived < configured → only conclusive when the analyzer saw every module
505
578
  // (`fullScope`). Under a scoped run an unscanned module may hold the
506
579
  // deeper chain, so this is deferred to the full-scope check (pre-push).
507
- let configFailed = false;
580
+ const configViolations = [];
508
581
  if (result.analyzed > 0 && configuredStructuralMax === undefined) {
509
582
  warn(` trace.structuralMax is unset — runtime step cap is open (truncation never fires). ` +
510
583
  `Add this to pipework.config.ts: trace: { structuralMax: ${String(result.structuralMax)} }`);
511
584
  }
512
585
  else if (result.analyzed > 0 && result.structuralMax > configuredStructuralMax) {
513
- logError(` trace.structuralMax is too low — config has ${String(configuredStructuralMax)}, ` +
514
- `analyzer derived ${String(result.structuralMax)}. A trace this deep exceeds the cap and ` +
515
- `would be truncated. Update pipework.config.ts: trace: { structuralMax: ${String(result.structuralMax)} }`);
516
- configFailed = true;
586
+ configViolations.push({
587
+ rule: 'cardinality/structural-max-too-low',
588
+ file: CONFIG_FILE,
589
+ line: 0,
590
+ message: `trace.structuralMax is too low — config has ${String(configuredStructuralMax)}, ` +
591
+ `analyzer derived ${String(result.structuralMax)}. A trace this deep exceeds the cap and ` +
592
+ `would be truncated. Update pipework.config.ts: trace: { structuralMax: ${String(result.structuralMax)} }`,
593
+ });
517
594
  }
518
595
  else if (result.analyzed > 0 && result.structuralMax < configuredStructuralMax && fullScope) {
519
- logError(` trace.structuralMax is stale — config has ${String(configuredStructuralMax)}, ` +
520
- `analyzer derived ${String(result.structuralMax)}. ` +
521
- `Update pipework.config.ts: trace: { structuralMax: ${String(result.structuralMax)} }`);
522
- configFailed = true;
523
- }
524
- if (result.violations.length === 0) {
525
- if (!configFailed) {
526
- success(`Flow cardinality (${String(result.analyzed)} flow step${result.analyzed === 1 ? '' : 's'} analyzed)`);
527
- }
528
- return !configFailed;
529
- }
530
- const grouped = groupByRule(result.violations);
531
- for (const [rule, items] of grouped) {
532
- logError(`${rule} (${String(items.length)} violation${items.length === 1 ? '' : 's'})`);
533
- for (const v of items.slice(0, 5)) {
534
- warn(` ${v.file}:${String(v.line)} — ${v.message}`);
535
- }
536
- if (items.length > 5) {
537
- warn(` ... and ${String(items.length - 5)} more`);
538
- }
539
- }
540
- return false;
596
+ configViolations.push({
597
+ rule: 'cardinality/structural-max-stale',
598
+ file: CONFIG_FILE,
599
+ line: 0,
600
+ message: `trace.structuralMax is stale — config has ${String(configuredStructuralMax)}, ` +
601
+ `analyzer derived ${String(result.structuralMax)}. ` +
602
+ `Update pipework.config.ts: trace: { structuralMax: ${String(result.structuralMax)} }`,
603
+ });
604
+ }
605
+ for (const v of configViolations)
606
+ logError(` ${v.message}`);
607
+ const violations = [...result.violations, ...configViolations];
608
+ if (violations.length === 0) {
609
+ success(`Flow cardinality (${String(result.analyzed)} flow step${result.analyzed === 1 ? '' : 's'} analyzed)`);
610
+ return stepRecord('cardinality', 'passed', [], result.scannedFiles);
611
+ }
612
+ printGrouped(result.violations);
613
+ return stepRecord('cardinality', 'failed', violations, result.scannedFiles);
541
614
  }
542
- export function runCoverageCheckWarning(cwd, sourceFiles, extras = {}) {
615
+ export function runCardinalityCheck(cwd, sourceFiles, configuredStructuralMax, fullScope = true) {
616
+ return cardinalityStep(cwd, sourceFiles, configuredStructuralMax, fullScope).status === 'passed';
617
+ }
618
+ export function coverageStep(cwd, sourceFiles, extras = {}) {
543
619
  info('Flow coverage (Tier-2, warning)');
544
620
  if (sourceFiles.length === 0) {
545
621
  success('Flow coverage (no source files)');
546
- return;
622
+ return stepRecord('coverage', 'passed', [], []);
547
623
  }
548
624
  const result = checkCoverage(cwd, sourceFiles, extras);
549
625
  if (result.violations.length === 0) {
550
626
  success(`Flow coverage (${String(result.scanned)} files scanned)`);
551
- return;
627
+ return stepRecord('coverage', 'passed', [], result.scannedFiles);
552
628
  }
553
- // Warning mode: emit diagnostics but never bump `failed`. The caller never
554
- // sees a return value — this rule cannot fail the build until Phase 7a.
629
+ // Warning mode: emit diagnostics but never fail. This rule cannot fail the
630
+ // build until Phase 7a, so the record is passed with its warnings listed.
555
631
  const grouped = groupByRule(result.violations);
556
632
  warn(`Flow coverage (${String(result.violations.length)} warning${result.violations.length === 1 ? '' : 's'})`);
557
633
  for (const [rule, items] of grouped) {
@@ -563,6 +639,10 @@ export function runCoverageCheckWarning(cwd, sourceFiles, extras = {}) {
563
639
  warn(` ... and ${String(items.length - 5)} more`);
564
640
  }
565
641
  }
642
+ return stepRecord('coverage', 'passed', result.violations, result.scannedFiles);
643
+ }
644
+ export function runCoverageCheckWarning(cwd, sourceFiles, extras = {}) {
645
+ coverageStep(cwd, sourceFiles, extras);
566
646
  }
567
647
  function groupByRule(items) {
568
648
  const map = new Map();
@@ -1126,7 +1206,7 @@ function runDependencyCheck(cwd) {
1126
1206
  // limits on constrained hosts (#322 ran these via `Promise.all`; that is the
1127
1207
  // OOM this reverts). The wall-clock cost of a serial loop over 2–3 modules is
1128
1208
  // negligible next to an OOM kill.
1129
- async function runLint(cwd, allModules, shard) {
1209
+ export async function runLint(cwd, allModules, shard) {
1130
1210
  const modulePaths = shardModules(allModules, shard);
1131
1211
  if (shard !== undefined && allModules.length > 0) {
1132
1212
  info(`Lint shard ${shardArg(shard)}: ${String(modulePaths.length)} of ${String(allModules.length)} module(s)`);
@@ -1136,6 +1216,7 @@ async function runLint(cwd, allModules, shard) {
1136
1216
  info('Lint');
1137
1217
  const eslintBin = resolveEslintBin(cwd);
1138
1218
  const includes = await loadLintIncludes(cwd);
1219
+ const population = lintPopulation(cwd, allModules, includes, shard);
1139
1220
  // Per-module eslint cache, version-stamped and under the workspace-root
1140
1221
  // node_modules/.cache — always present, always gitignored. The version stamp
1141
1222
  // busts the cache on a pipework ruleset bump; the node_modules location keeps
@@ -1150,8 +1231,8 @@ async function runLint(cwd, allModules, shard) {
1150
1231
  return ok;
1151
1232
  }
1152
1233
  let ok = true;
1153
- for (const mod of modulePaths) {
1154
- if (!await lintDirectory(eslintBin, join(cwd, mod), mod, cacheFor(mod), includes.for(mod))) {
1234
+ for (const mod of population.modules) {
1235
+ if (!await lintDirectory(eslintBin, join(cwd, mod.path), mod.path, cacheFor(mod.path), mod.includes)) {
1155
1236
  ok = false;
1156
1237
  }
1157
1238
  }
@@ -1159,16 +1240,14 @@ async function runLint(cwd, allModules, shard) {
1159
1240
  // root, outside every module. Those files were missing from the files-linted
1160
1241
  // count entirely — a coverage reading produced from a population that excluded
1161
1242
  // them. They get their own pass, counted and named like any module's.
1162
- const rootOnly = shard !== undefined && shard.index !== 1
1163
- ? []
1164
- : rootFilesOutsideModules(cwd, allModules, includes.for('.'));
1243
+ const rootOnly = population.root;
1165
1244
  if (rootOnly.length > 0) {
1166
1245
  if (!await lintDirectory(eslintBin, cwd, 'root', cacheFor('__root'), rootOnly)) {
1167
1246
  ok = false;
1168
1247
  }
1169
1248
  }
1170
1249
  if (ok) {
1171
- const modules = `${String(modulePaths.length)} module${modulePaths.length === 1 ? '' : 's'}`;
1250
+ const modules = `${String(population.modules.length)} module${population.modules.length === 1 ? '' : 's'}`;
1172
1251
  const root = rootOnly.length > 0
1173
1252
  ? ` + ${String(rootOnly.length)} root file${rootOnly.length === 1 ? '' : 's'}`
1174
1253
  : '';
@@ -1176,6 +1255,26 @@ async function runLint(cwd, allModules, shard) {
1176
1255
  }
1177
1256
  return ok;
1178
1257
  }
1258
+ /**
1259
+ * The files `check`'s lint pass covers: each sharded module's include globs plus the
1260
+ * root-outside-modules pass (shard 1 only). `runLint` lints this population and
1261
+ * `pipework lint --list-files` prints it, so the two cannot drift.
1262
+ */
1263
+ export function lintPopulation(cwd, allModules, includes, shard) {
1264
+ const modules = shardModules([...allModules], shard).map(path => ({ path, includes: includes.for(path) }));
1265
+ const root = shard !== undefined && shard.index !== 1
1266
+ ? []
1267
+ : rootFilesOutsideModules(cwd, [...allModules], includes.for('.'));
1268
+ const files = new Set(root);
1269
+ for (const mod of modules) {
1270
+ const dir = join(cwd, mod.path);
1271
+ const prefix = normalizeModulePath(mod.path);
1272
+ for (const f of filesMatchingGlobs(dir, lintIncludeGlobs(dir, mod.includes))) {
1273
+ files.add(prefix === '' ? f : `${prefix}/${f}`);
1274
+ }
1275
+ }
1276
+ return { modules, root, files: [...files].sort() };
1277
+ }
1179
1278
  /**
1180
1279
  * Root-level lintable files that no module covers (#402).
1181
1280
  *