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
 
@@ -26,6 +26,24 @@ import { StartupValidationError } from '../../core/validation/types.js'
26
26
  import type { Manifold } from '../../core/pipework.js'
27
27
  import type { LintConfig, VitestConfig } from '../../core/config/schema.js'
28
28
  import { shardArg, shardModules, type Shard } from './shard.js'
29
+ import { record, emitJson, type StepName, type StepRecord, type StepSelection, type StepViolation } from './check-steps.js'
30
+
31
+ function stepRecord(
32
+ step: StepName,
33
+ status: StepRecord['status'],
34
+ violations: readonly StepViolation[] = [],
35
+ scannedFiles?: readonly string[],
36
+ ): StepRecord {
37
+ return record(step, pipeworkVersion(), status, violations, scannedFiles)
38
+ }
39
+
40
+ export interface CheckOptions {
41
+ /** `json`: one record per step on stdout (or `outputFile`); human lines stay on stderr. */
42
+ readonly format?: 'human' | 'json'
43
+ readonly outputFile?: string | undefined
44
+ /** Which steps run; the rest are recorded as skipped. */
45
+ readonly select?: StepSelection
46
+ }
29
47
 
30
48
  // #322: discoverInstance evaluates pipework.config.ts — a dynamic import plus a
31
49
  // full module evaluation. A single `check` run needs it from four call sites;
@@ -42,24 +60,36 @@ async function discoverInstanceCached(cwd: string): Promise<Manifold> {
42
60
  return loading
43
61
  }
44
62
 
45
- export async function check(cwd: string, scope?: ScopeMode, shard?: Shard): Promise<void> {
63
+ export async function check(cwd: string, scope?: ScopeMode, shard?: Shard, options: CheckOptions = {}): Promise<void> {
46
64
  instanceCache = new Map()
47
65
  let failed = false
48
-
49
- const checksumOk = verifyChecksums(cwd)
50
- if (!checksumOk) {
51
- failed = true
52
- }
53
-
54
- const vitestOk = await verifyVitestConfig(cwd)
55
- if (!vitestOk) {
56
- failed = true
57
- }
58
-
59
- const eslintOk = verifyEslintConfig(cwd)
60
- if (!eslintOk) {
61
- failed = true
62
- }
66
+ const selected = options.select ?? (() => true)
67
+ const records: StepRecord[] = []
68
+
69
+ // A deselected step is named in the human output; a step whose precondition is
70
+ // absent is skipped as it always was, without a line.
71
+ const skip = (step: StepName, label: string, reason: string): void => {
72
+ if (!selected(step)) warn(`${label} — skipped (not selected)`)
73
+ records.push(record(step, pipeworkVersion(), 'skipped', [], undefined, selected(step) ? reason : 'not selected'))
74
+ }
75
+ // A step that prints its own lines and answers pass/fail.
76
+ const plain = async (step: StepName, label: string, fn: () => boolean | Promise<boolean>): Promise<void> => {
77
+ if (!selected(step)) return skip(step, label, '')
78
+ const ok = await fn()
79
+ records.push(stepRecord(step, ok ? 'passed' : 'failed'))
80
+ if (!ok) failed = true
81
+ }
82
+ // A step that prints its own lines and returns its record.
83
+ const rich = async (step: StepName, label: string, fn: () => StepRecord | Promise<StepRecord>): Promise<void> => {
84
+ if (!selected(step)) return skip(step, label, '')
85
+ const result = await fn()
86
+ records.push(result)
87
+ if (result.status === 'failed') failed = true
88
+ }
89
+
90
+ await plain('checksums', 'Workspace checksums', () => verifyChecksums(cwd))
91
+ await plain('vitest-config', 'Vitest config', () => verifyVitestConfig(cwd))
92
+ await plain('eslint-config', 'Eslint config', () => verifyEslintConfig(cwd))
63
93
 
64
94
  const { paths: modulePaths, all: fullScope, configError, basis } = await resolveModulePaths(cwd, scope)
65
95
 
@@ -70,93 +100,143 @@ export async function check(cwd: string, scope?: ScopeMode, shard?: Shard): Prom
70
100
  // 0 with "All checks passed". A step that cannot run is a failed step: the
71
101
  // load failure is reported here and fails the run. The remaining steps still
72
102
  // execute so one run surfaces everything it can, but none of them can green it.
73
- if (configError !== undefined) {
103
+ if (!selected('config-load')) {
104
+ skip('config-load', 'Config load', '')
105
+ } else if (configError !== undefined) {
74
106
  info('Config load')
75
107
  logError('Config load — pipework.config.ts could not be loaded. Config-derived steps ran degraded or not at all.')
76
108
  for (const line of configError.message.split('\n')) {
77
109
  process.stderr.write(` ${line}\n`)
78
110
  }
111
+ records.push(stepRecord('config-load', 'failed', [{ rule: 'config/load', file: CONFIG_FILE, line: 0, message: configError.message }]))
79
112
  failed = true
113
+ } else {
114
+ records.push(stepRecord('config-load', 'passed'))
80
115
  }
81
116
 
82
117
  if (modulePaths.length > 0) {
83
118
  const sourceFiles = findSourceFiles(cwd, modulePaths)
84
119
 
85
- const doctrineOk = runDoctrineValidation(cwd, sourceFiles)
86
- if (!doctrineOk) {
87
- failed = true
88
- }
120
+ await rich('doctrine', 'Doctrine validation', () => doctrineStep(cwd, sourceFiles))
89
121
 
90
122
  // Phase 4b: the flow and cardinality checks are hard errors. Consumers
91
123
  // upgrading from an earlier release run `pipework codemod` once to fill
92
124
  // missing `@downstream` cardinalities; anything the codemod cannot
93
125
  // resolve (indirection, non-trivial form, method flow steps, orphans)
94
126
  // must be fixed by hand before the build will pass.
95
- if (!runFlowCheck(cwd, sourceFiles)) {
96
- failed = true
97
- }
98
- const configuredStructuralMax = await loadConfiguredStructuralMax(cwd)
99
- if (!runCardinalityCheck(cwd, sourceFiles, configuredStructuralMax, fullScope)) {
100
- failed = true
101
- }
127
+ await rich('flow', 'Flow annotations', () => flowStep(cwd, sourceFiles))
128
+ await rich('cardinality', 'Flow cardinality', async () =>
129
+ cardinalityStep(cwd, sourceFiles, await loadConfiguredStructuralMax(cwd), fullScope))
102
130
 
103
131
  // Phase 6f: Tier-2 coverage ships as warning. Surfaces structural gaps —
104
132
  // I/O outside any flow_step, more than one I/O in a single flow_step —
105
133
  // without failing the build. Phase 7a flips it to error once consumer
106
134
  // corpora have migrated to the new write surface. Consumers extend the
107
135
  // I/O call-target registry through `trace.io` in pipework.config.ts (Q9).
108
- const consumerIoPatterns = await loadConsumerIoPatterns(cwd)
109
- runCoverageCheckWarning(cwd, sourceFiles, consumerIoPatterns)
136
+ await rich('coverage', 'Flow coverage', async () =>
137
+ coverageStep(cwd, sourceFiles, await loadConsumerIoPatterns(cwd)))
110
138
 
111
- const lintOk = await runLint(cwd, modulePaths, shard)
112
- if (!lintOk) {
113
- failed = true
139
+ await plain('lint', 'Lint', () => runLint(cwd, modulePaths, shard))
140
+ await plain('typecheck', 'Type check', () => runTypeCheck(cwd, modulePaths, shard))
141
+ } else {
142
+ for (const [step, label] of [
143
+ ['doctrine', 'Doctrine validation'], ['flow', 'Flow annotations'], ['cardinality', 'Flow cardinality'],
144
+ ['coverage', 'Flow coverage'], ['lint', 'Lint'], ['typecheck', 'Type check'],
145
+ ] as const) {
146
+ skip(step, label, 'no modules resolved')
114
147
  }
115
-
116
- const typeCheckOk = await runTypeCheck(cwd, modulePaths, shard)
117
- if (!typeCheckOk) {
118
- failed = true
148
+ if (scope !== undefined && scope.kind === 'diff') {
149
+ // The basis says WHY the set is empty — a green here must state the scope
150
+ // it rested on, or an empty basis reads as "everything passed".
151
+ success(`No affected modules — per-module checks skipped (basis: ${basis ?? 'unknown'})`)
152
+ } else {
153
+ // Not a diff scope, so this is not "nothing changed" — no module could be
154
+ // resolved at all. Every per-module step was skipped; say so rather than
155
+ // letting their absence read as a clean pass (#335).
156
+ warn('No modules resolved — lint, type check, doctrine and flow checks did NOT run')
119
157
  }
120
- } else if (scope !== undefined && scope.kind === 'diff') {
121
- // The basis says WHY the set is empty — a green here must state the scope
122
- // it rested on, or an empty basis reads as "everything passed".
123
- success(`No affected modules — per-module checks skipped (basis: ${basis ?? 'unknown'})`)
124
- } else {
125
- // Not a diff scope, so this is not "nothing changed" — no module could be
126
- // resolved at all. Every per-module step was skipped; say so rather than
127
- // letting their absence read as a clean pass (#335).
128
- warn('No modules resolved — lint, type check, doctrine and flow checks did NOT run')
129
158
  }
130
159
 
131
160
  if (configError === undefined && findConfig(cwd) !== null) {
132
- if (!(await runStartupChecks(cwd))) {
133
- failed = true
134
- }
161
+ await plain('startup', 'Startup checks', () => runStartupChecks(cwd))
162
+ } else {
163
+ skip('startup', 'Startup checks', 'no loadable pipework.config.ts')
135
164
  }
136
165
 
137
166
  const boundaryScript = join(cwd, 'scripts', 'check-boundaries.js')
138
167
  if (existsSync(boundaryScript)) {
139
- if (!runStep('Boundaries', 'node scripts/check-boundaries.js', cwd)) {
140
- failed = true
141
- }
168
+ await plain('boundaries', 'Boundaries', () => runStep('Boundaries', 'node scripts/check-boundaries.js', cwd))
169
+ } else {
170
+ skip('boundaries', 'Boundaries', 'no scripts/check-boundaries.js')
142
171
  }
143
172
 
144
- const depCheckOk = runDependencyCheck(cwd)
145
- if (!depCheckOk) {
146
- failed = true
147
- }
173
+ await plain('dependencies', 'Dependency boundaries', () => runDependencyCheck(cwd))
174
+ await plain('install', 'pipework install integrity', () => runPipeworkCopiesCheck(cwd))
148
175
 
149
- const copiesOk = runPipeworkCopiesCheck(cwd)
150
- if (!copiesOk) {
151
- failed = true
152
- }
176
+ if (options.format === 'json') emitJson(records, options.outputFile)
153
177
 
154
178
  if (failed) process.exit(1)
155
- success('All checks passed')
179
+ const deselected = records.filter(r => r.reason === 'not selected').map(r => r.step)
180
+ success(deselected.length === 0 ? 'All checks passed' : `Selected checks passed (not run: ${deselected.join(', ')})`)
181
+ }
182
+
183
+ export interface StepCommandOptions {
184
+ readonly format?: 'human' | 'json'
185
+ readonly outputFile?: string | undefined
186
+ readonly scope?: ScopeMode
187
+ /** Explicit files (doctrine, flow, cardinality): only these are judged. */
188
+ readonly files?: readonly string[]
189
+ }
190
+
191
+ /** `pipework doctrine|flow|cardinality|startup`: one step alone. Returns the exit code. */
192
+ export async function stepCommand(
193
+ cwd: string,
194
+ step: 'doctrine' | 'flow' | 'cardinality' | 'startup',
195
+ options: StepCommandOptions = {},
196
+ ): Promise<number> {
197
+ instanceCache = new Map()
198
+ const files = options.files ?? []
199
+ let result: StepRecord
200
+
201
+ if (step === 'startup') {
202
+ if (files.length > 0) throw new Error('startup takes no files.')
203
+ result = await startupStep(cwd)
204
+ } else {
205
+ let sourceFiles: string[]
206
+ let fullScope = false
207
+ let configError: Error | undefined
208
+ if (files.length > 0) {
209
+ sourceFiles = files.map(f => {
210
+ const rel = relative(cwd, resolve(cwd, f))
211
+ if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`${f} is outside ${cwd}.`)
212
+ if (!existsSync(join(cwd, rel))) throw new Error(`${f} does not exist.`)
213
+ return rel.split(sep).join('/')
214
+ })
215
+ } else {
216
+ const resolved = await resolveModulePaths(cwd, options.scope)
217
+ configError = resolved.configError
218
+ fullScope = resolved.all
219
+ sourceFiles = findSourceFiles(cwd, resolved.paths)
220
+ }
221
+ if (configError !== undefined) {
222
+ logError('Config load — pipework.config.ts could not be loaded.')
223
+ process.stderr.write(` ${configError.message}\n`)
224
+ result = stepRecord(step, 'failed', [{ rule: 'config/load', file: CONFIG_FILE, line: 0, message: configError.message }])
225
+ } else if (step === 'doctrine') {
226
+ result = doctrineStep(cwd, sourceFiles)
227
+ } else if (step === 'flow') {
228
+ result = flowStep(cwd, sourceFiles)
229
+ } else {
230
+ result = cardinalityStep(cwd, sourceFiles, await loadConfiguredStructuralMax(cwd), fullScope)
231
+ }
232
+ }
233
+
234
+ if (options.format === 'json') emitJson(result, options.outputFile)
235
+ return result.status === 'failed' ? 1 : 0
156
236
  }
157
237
 
158
238
  /** The startup checks that need no database — what `serve` would refuse before connecting. */
159
- export async function runStartupChecks(cwd: string): Promise<boolean> {
239
+ export async function startupStep(cwd: string): Promise<StepRecord> {
160
240
  info('Startup checks')
161
241
  let doors: readonly string[] = []
162
242
  let roles: readonly string[] = []
@@ -173,11 +253,14 @@ export async function runStartupChecks(cwd: string): Promise<boolean> {
173
253
  for (const issue of err.issues) {
174
254
  process.stderr.write(` [${issue.phase}] ${issue.message}\n`)
175
255
  }
176
- } else {
177
- logError('Startup checks — could not run')
178
- process.stderr.write(` ${err instanceof Error ? err.message : String(err)}\n`)
256
+ return stepRecord('startup', 'failed', err.issues.map(issue => ({
257
+ rule: `startup/${issue.phase}`, file: CONFIG_FILE, line: 0, message: issue.message,
258
+ })))
179
259
  }
180
- return false
260
+ logError('Startup checks — could not run')
261
+ const message = err instanceof Error ? err.message : String(err)
262
+ process.stderr.write(` ${message}\n`)
263
+ return stepRecord('startup', 'failed', [{ rule: 'startup/could-not-run', file: CONFIG_FILE, line: 0, message }])
181
264
  }
182
265
  success('Startup checks')
183
266
  for (const line of roles) info(line)
@@ -185,7 +268,11 @@ export async function runStartupChecks(cwd: string): Promise<boolean> {
185
268
  success(`Doors (${String(doors.length)} declared)`)
186
269
  for (const line of doors) info(` ${line}`)
187
270
  }
188
- return true
271
+ return stepRecord('startup', 'passed')
272
+ }
273
+
274
+ export async function runStartupChecks(cwd: string): Promise<boolean> {
275
+ return (await startupStep(cwd)).status === 'passed'
189
276
  }
190
277
 
191
278
  /** One line per platform-mode database: the role a running manifold connects as, and the only callers that may open the owner. */
@@ -505,74 +592,74 @@ function findStaleVitestConfigs(cwd: string): string[] {
505
592
  return stale.sort()
506
593
  }
507
594
 
508
- function runDoctrineValidation(cwd: string, sourceFiles: string[]): boolean {
595
+ function printGrouped(violations: readonly StepViolation[]): void {
596
+ const grouped = groupByRule(violations)
597
+ for (const [rule, items] of grouped) {
598
+ logError(`${rule} (${String(items.length)} violation${items.length === 1 ? '' : 's'})`)
599
+ for (const v of items.slice(0, 5)) {
600
+ warn(` ${v.file}:${String(v.line)} — ${v.message}`)
601
+ }
602
+ if (items.length > 5) {
603
+ warn(` ... and ${String(items.length - 5)} more`)
604
+ }
605
+ }
606
+ }
607
+
608
+ export function doctrineStep(cwd: string, sourceFiles: readonly string[]): StepRecord {
509
609
  info('Doctrine validation')
510
610
 
511
611
  if (sourceFiles.length === 0) {
512
612
  success('Doctrine validation (no source files)')
513
- return true
613
+ return stepRecord('doctrine', 'passed', [], [])
514
614
  }
515
615
 
516
616
  const result = validateDoctrine(cwd, sourceFiles)
517
617
 
518
618
  if (result.violations.length === 0) {
519
619
  success(`Doctrine validation (${String(result.scanned)} files scanned)`)
520
- return true
521
- }
522
-
523
- const grouped = groupByRule(result.violations)
524
- for (const [rule, items] of grouped) {
525
- logError(`${rule} (${String(items.length)} violation${items.length === 1 ? '' : 's'})`)
526
- for (const v of items.slice(0, 5)) {
527
- warn(` ${v.file}:${String(v.line)} — ${v.message}`)
528
- }
529
- if (items.length > 5) {
530
- warn(` ... and ${String(items.length - 5)} more`)
531
- }
620
+ return stepRecord('doctrine', 'passed', [], result.scannedFiles)
532
621
  }
533
622
 
534
- return false
623
+ printGrouped(result.violations)
624
+ return stepRecord('doctrine', 'failed', result.violations, result.scannedFiles)
535
625
  }
536
626
 
537
- export function runFlowCheck(cwd: string, sourceFiles: string[]): boolean {
627
+ export function flowStep(cwd: string, sourceFiles: readonly string[]): StepRecord {
538
628
  info('Flow annotations')
539
629
 
540
630
  if (sourceFiles.length === 0) {
541
631
  success('Flow annotations (no source files)')
542
- return true
632
+ return stepRecord('flow', 'passed', [], [])
543
633
  }
544
634
 
545
635
  const result = checkFlowAnnotations(cwd, sourceFiles)
546
636
 
547
637
  if (result.violations.length === 0) {
548
638
  success(`Flow annotations (${String(result.scanned)} files scanned)`)
549
- return true
639
+ return stepRecord('flow', 'passed', [], result.scannedFiles)
550
640
  }
551
641
 
552
- const grouped = groupByRule(result.violations)
553
- for (const [rule, items] of grouped) {
554
- logError(`${rule} (${String(items.length)} violation${items.length === 1 ? '' : 's'})`)
555
- for (const v of items.slice(0, 5)) {
556
- warn(` ${v.file}:${String(v.line)} — ${v.message}`)
557
- }
558
- if (items.length > 5) {
559
- warn(` ... and ${String(items.length - 5)} more`)
560
- }
561
- }
562
- return false
642
+ printGrouped(result.violations)
643
+ return stepRecord('flow', 'failed', result.violations, result.scannedFiles)
563
644
  }
564
645
 
565
- export function runCardinalityCheck(
646
+ export function runFlowCheck(cwd: string, sourceFiles: string[]): boolean {
647
+ return flowStep(cwd, sourceFiles).status === 'passed'
648
+ }
649
+
650
+ const CONFIG_FILE = 'pipework.config.ts'
651
+
652
+ export function cardinalityStep(
566
653
  cwd: string,
567
- sourceFiles: string[],
654
+ sourceFiles: readonly string[],
568
655
  configuredStructuralMax?: number,
569
656
  fullScope = true,
570
- ): boolean {
657
+ ): StepRecord {
571
658
  info('Flow cardinality')
572
659
 
573
660
  if (sourceFiles.length === 0) {
574
661
  success('Flow cardinality (no source files)')
575
- return true
662
+ return stepRecord('cardinality', 'passed', [], [])
576
663
  }
577
664
 
578
665
  const result = checkCardinality(cwd, sourceFiles)
@@ -593,69 +680,75 @@ export function runCardinalityCheck(
593
680
  // derived < configured → only conclusive when the analyzer saw every module
594
681
  // (`fullScope`). Under a scoped run an unscanned module may hold the
595
682
  // deeper chain, so this is deferred to the full-scope check (pre-push).
596
- let configFailed = false
683
+ const configViolations: StepViolation[] = []
597
684
  if (result.analyzed > 0 && configuredStructuralMax === undefined) {
598
685
  warn(
599
686
  ` trace.structuralMax is unset — runtime step cap is open (truncation never fires). ` +
600
687
  `Add this to pipework.config.ts: trace: { structuralMax: ${String(result.structuralMax)} }`,
601
688
  )
602
689
  } else if (result.analyzed > 0 && result.structuralMax > configuredStructuralMax!) {
603
- logError(
604
- ` trace.structuralMax is too low — config has ${String(configuredStructuralMax)}, ` +
605
- `analyzer derived ${String(result.structuralMax)}. A trace this deep exceeds the cap and ` +
606
- `would be truncated. Update pipework.config.ts: trace: { structuralMax: ${String(result.structuralMax)} }`,
607
- )
608
- configFailed = true
690
+ configViolations.push({
691
+ rule: 'cardinality/structural-max-too-low',
692
+ file: CONFIG_FILE,
693
+ line: 0,
694
+ message:
695
+ `trace.structuralMax is too low — config has ${String(configuredStructuralMax)}, ` +
696
+ `analyzer derived ${String(result.structuralMax)}. A trace this deep exceeds the cap and ` +
697
+ `would be truncated. Update pipework.config.ts: trace: { structuralMax: ${String(result.structuralMax)} }`,
698
+ })
609
699
  } else if (result.analyzed > 0 && result.structuralMax < configuredStructuralMax! && fullScope) {
610
- logError(
611
- ` trace.structuralMax is stale — config has ${String(configuredStructuralMax)}, ` +
612
- `analyzer derived ${String(result.structuralMax)}. ` +
613
- `Update pipework.config.ts: trace: { structuralMax: ${String(result.structuralMax)} }`,
614
- )
615
- configFailed = true
616
- }
617
-
618
- if (result.violations.length === 0) {
619
- if (!configFailed) {
620
- success(`Flow cardinality (${String(result.analyzed)} flow step${result.analyzed === 1 ? '' : 's'} analyzed)`)
621
- }
622
- return !configFailed
623
- }
624
-
625
- const grouped = groupByRule(result.violations)
626
- for (const [rule, items] of grouped) {
627
- logError(`${rule} (${String(items.length)} violation${items.length === 1 ? '' : 's'})`)
628
- for (const v of items.slice(0, 5)) {
629
- warn(` ${v.file}:${String(v.line)} — ${v.message}`)
630
- }
631
- if (items.length > 5) {
632
- warn(` ... and ${String(items.length - 5)} more`)
633
- }
634
- }
635
- return false
700
+ configViolations.push({
701
+ rule: 'cardinality/structural-max-stale',
702
+ file: CONFIG_FILE,
703
+ line: 0,
704
+ message:
705
+ `trace.structuralMax is stale — config has ${String(configuredStructuralMax)}, ` +
706
+ `analyzer derived ${String(result.structuralMax)}. ` +
707
+ `Update pipework.config.ts: trace: { structuralMax: ${String(result.structuralMax)} }`,
708
+ })
709
+ }
710
+ for (const v of configViolations) logError(` ${v.message}`)
711
+
712
+ const violations = [...result.violations, ...configViolations]
713
+ if (violations.length === 0) {
714
+ success(`Flow cardinality (${String(result.analyzed)} flow step${result.analyzed === 1 ? '' : 's'} analyzed)`)
715
+ return stepRecord('cardinality', 'passed', [], result.scannedFiles)
716
+ }
717
+
718
+ printGrouped(result.violations)
719
+ return stepRecord('cardinality', 'failed', violations, result.scannedFiles)
636
720
  }
637
721
 
638
- export function runCoverageCheckWarning(
722
+ export function runCardinalityCheck(
639
723
  cwd: string,
640
724
  sourceFiles: string[],
725
+ configuredStructuralMax?: number,
726
+ fullScope = true,
727
+ ): boolean {
728
+ return cardinalityStep(cwd, sourceFiles, configuredStructuralMax, fullScope).status === 'passed'
729
+ }
730
+
731
+ export function coverageStep(
732
+ cwd: string,
733
+ sourceFiles: readonly string[],
641
734
  extras: Readonly<Record<string, 'read' | 'write'>> = {},
642
- ): void {
735
+ ): StepRecord {
643
736
  info('Flow coverage (Tier-2, warning)')
644
737
 
645
738
  if (sourceFiles.length === 0) {
646
739
  success('Flow coverage (no source files)')
647
- return
740
+ return stepRecord('coverage', 'passed', [], [])
648
741
  }
649
742
 
650
743
  const result = checkCoverage(cwd, sourceFiles, extras)
651
744
 
652
745
  if (result.violations.length === 0) {
653
746
  success(`Flow coverage (${String(result.scanned)} files scanned)`)
654
- return
747
+ return stepRecord('coverage', 'passed', [], result.scannedFiles)
655
748
  }
656
749
 
657
- // Warning mode: emit diagnostics but never bump `failed`. The caller never
658
- // sees a return value — this rule cannot fail the build until Phase 7a.
750
+ // Warning mode: emit diagnostics but never fail. This rule cannot fail the
751
+ // build until Phase 7a, so the record is passed with its warnings listed.
659
752
  const grouped = groupByRule(result.violations)
660
753
  warn(`Flow coverage (${String(result.violations.length)} warning${result.violations.length === 1 ? '' : 's'})`)
661
754
  for (const [rule, items] of grouped) {
@@ -667,6 +760,15 @@ export function runCoverageCheckWarning(
667
760
  warn(` ... and ${String(items.length - 5)} more`)
668
761
  }
669
762
  }
763
+ return stepRecord('coverage', 'passed', result.violations, result.scannedFiles)
764
+ }
765
+
766
+ export function runCoverageCheckWarning(
767
+ cwd: string,
768
+ sourceFiles: string[],
769
+ extras: Readonly<Record<string, 'read' | 'write'>> = {},
770
+ ): void {
771
+ coverageStep(cwd, sourceFiles, extras)
670
772
  }
671
773
 
672
774
  function groupByRule<T extends { readonly rule: string }>(items: readonly T[]): Map<string, T[]> {
@@ -1273,7 +1375,7 @@ function runDependencyCheck(cwd: string): boolean {
1273
1375
  // limits on constrained hosts (#322 ran these via `Promise.all`; that is the
1274
1376
  // OOM this reverts). The wall-clock cost of a serial loop over 2–3 modules is
1275
1377
  // negligible next to an OOM kill.
1276
- async function runLint(cwd: string, allModules: string[], shard?: Shard): Promise<boolean> {
1378
+ export async function runLint(cwd: string, allModules: string[], shard?: Shard): Promise<boolean> {
1277
1379
  const modulePaths = shardModules(allModules, shard)
1278
1380
  if (shard !== undefined && allModules.length > 0) {
1279
1381
  info(`Lint shard ${shardArg(shard)}: ${String(modulePaths.length)} of ${String(allModules.length)} module(s)`)
@@ -1283,6 +1385,7 @@ async function runLint(cwd: string, allModules: string[], shard?: Shard): Promis
1283
1385
 
1284
1386
  const eslintBin = resolveEslintBin(cwd)
1285
1387
  const includes = await loadLintIncludes(cwd)
1388
+ const population = lintPopulation(cwd, allModules, includes, shard)
1286
1389
 
1287
1390
  // Per-module eslint cache, version-stamped and under the workspace-root
1288
1391
  // node_modules/.cache — always present, always gitignored. The version stamp
@@ -1300,8 +1403,8 @@ async function runLint(cwd: string, allModules: string[], shard?: Shard): Promis
1300
1403
  }
1301
1404
 
1302
1405
  let ok = true
1303
- for (const mod of modulePaths) {
1304
- if (!await lintDirectory(eslintBin, join(cwd, mod), mod, cacheFor(mod), includes.for(mod))) {
1406
+ for (const mod of population.modules) {
1407
+ if (!await lintDirectory(eslintBin, join(cwd, mod.path), mod.path, cacheFor(mod.path), mod.includes)) {
1305
1408
  ok = false
1306
1409
  }
1307
1410
  }
@@ -1310,9 +1413,7 @@ async function runLint(cwd: string, allModules: string[], shard?: Shard): Promis
1310
1413
  // root, outside every module. Those files were missing from the files-linted
1311
1414
  // count entirely — a coverage reading produced from a population that excluded
1312
1415
  // them. They get their own pass, counted and named like any module's.
1313
- const rootOnly = shard !== undefined && shard.index !== 1
1314
- ? []
1315
- : rootFilesOutsideModules(cwd, allModules, includes.for('.'))
1416
+ const rootOnly = population.root
1316
1417
  if (rootOnly.length > 0) {
1317
1418
  if (!await lintDirectory(eslintBin, cwd, 'root', cacheFor('__root'), rootOnly)) {
1318
1419
  ok = false
@@ -1320,7 +1421,7 @@ async function runLint(cwd: string, allModules: string[], shard?: Shard): Promis
1320
1421
  }
1321
1422
 
1322
1423
  if (ok) {
1323
- const modules = `${String(modulePaths.length)} module${modulePaths.length === 1 ? '' : 's'}`
1424
+ const modules = `${String(population.modules.length)} module${population.modules.length === 1 ? '' : 's'}`
1324
1425
  const root = rootOnly.length > 0
1325
1426
  ? ` + ${String(rootOnly.length)} root file${rootOnly.length === 1 ? '' : 's'}`
1326
1427
  : ''
@@ -1329,6 +1430,41 @@ async function runLint(cwd: string, allModules: string[], shard?: Shard): Promis
1329
1430
  return ok
1330
1431
  }
1331
1432
 
1433
+ export interface LintPopulation {
1434
+ /** The sharded modules, each with the include globs `check` lints it with. */
1435
+ readonly modules: ReadonlyArray<{ readonly path: string; readonly includes: readonly string[] | undefined }>
1436
+ /** Root files outside every module; empty off shard 1. */
1437
+ readonly root: string[]
1438
+ /** Every file the run lints, repo-relative and sorted. */
1439
+ readonly files: string[]
1440
+ }
1441
+
1442
+ /**
1443
+ * The files `check`'s lint pass covers: each sharded module's include globs plus the
1444
+ * root-outside-modules pass (shard 1 only). `runLint` lints this population and
1445
+ * `pipework lint --list-files` prints it, so the two cannot drift.
1446
+ */
1447
+ export function lintPopulation(
1448
+ cwd: string,
1449
+ allModules: readonly string[],
1450
+ includes: { for: (mod: string) => readonly string[] | undefined },
1451
+ shard?: Shard,
1452
+ ): LintPopulation {
1453
+ const modules = shardModules([...allModules], shard).map(path => ({ path, includes: includes.for(path) }))
1454
+ const root = shard !== undefined && shard.index !== 1
1455
+ ? []
1456
+ : rootFilesOutsideModules(cwd, [...allModules], includes.for('.'))
1457
+ const files = new Set<string>(root)
1458
+ for (const mod of modules) {
1459
+ const dir = join(cwd, mod.path)
1460
+ const prefix = normalizeModulePath(mod.path)
1461
+ for (const f of filesMatchingGlobs(dir, lintIncludeGlobs(dir, mod.includes))) {
1462
+ files.add(prefix === '' ? f : `${prefix}/${f}`)
1463
+ }
1464
+ }
1465
+ return { modules, root, files: [...files].sort() }
1466
+ }
1467
+
1332
1468
  /**
1333
1469
  * Root-level lintable files that no module covers (#402).
1334
1470
  *