pipework 0.269.0 → 0.271.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.
- package/CHANGELOG.md +9 -0
- package/REFERENCE.md +44 -0
- package/dist/cli/commands/check-steps.d.ts +34 -0
- package/dist/cli/commands/check-steps.d.ts.map +1 -0
- package/dist/cli/commands/check-steps.js +60 -0
- package/dist/cli/commands/check-steps.js.map +1 -0
- package/dist/cli/commands/check.d.ts +43 -1
- package/dist/cli/commands/check.d.ts.map +1 -1
- package/dist/cli/commands/check.js +232 -133
- package/dist/cli/commands/check.js.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +36 -1
- package/dist/cli/commands/lint.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +55 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/lint/config.d.ts +4 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/dist/lint/config.js +6 -0
- package/dist/lint/config.js.map +1 -1
- package/dist/lint/doctrine.d.ts +4 -0
- package/dist/lint/doctrine.d.ts.map +1 -1
- package/dist/lint/doctrine.js +14 -7
- package/dist/lint/doctrine.js.map +1 -1
- package/dist/lint/rules/doctrine.d.ts +3 -0
- package/dist/lint/rules/doctrine.d.ts.map +1 -0
- package/dist/lint/rules/doctrine.js +28 -0
- package/dist/lint/rules/doctrine.js.map +1 -0
- package/dist/trace/cardinality/check.d.ts +5 -1
- package/dist/trace/cardinality/check.d.ts.map +1 -1
- package/dist/trace/cardinality/check.js +3 -1
- package/dist/trace/cardinality/check.js.map +1 -1
- package/dist/trace/check/coverage-check.d.ts +2 -0
- package/dist/trace/check/coverage-check.d.ts.map +1 -1
- package/dist/trace/check/coverage-check.js +3 -1
- package/dist/trace/check/coverage-check.js.map +1 -1
- package/dist/trace/check/flow-check.d.ts +2 -0
- package/dist/trace/check/flow-check.d.ts.map +1 -1
- package/dist/trace/check/flow-check.js +3 -1
- package/dist/trace/check/flow-check.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/check-steps.ts +98 -0
- package/src/cli/commands/check.ts +283 -147
- package/src/cli/commands/lint.ts +34 -1
- package/src/cli/index.ts +56 -2
- package/src/lint/config.ts +6 -0
- package/src/lint/doctrine.ts +17 -8
- package/src/lint/rules/doctrine.ts +31 -0
- package/src/trace/cardinality/check.ts +9 -2
- package/src/trace/check/coverage-check.ts +5 -1
- 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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
109
|
-
|
|
136
|
+
await rich('coverage', 'Flow coverage', async () =>
|
|
137
|
+
coverageStep(cwd, sourceFiles, await loadConsumerIoPatterns(cwd)))
|
|
110
138
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
failed = true
|
|
147
|
-
}
|
|
173
|
+
await plain('dependencies', 'Dependency boundaries', () => runDependencyCheck(cwd))
|
|
174
|
+
await plain('install', 'pipework install integrity', () => runPipeworkCopiesCheck(cwd))
|
|
148
175
|
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
623
|
+
printGrouped(result.violations)
|
|
624
|
+
return stepRecord('doctrine', 'failed', result.violations, result.scannedFiles)
|
|
535
625
|
}
|
|
536
626
|
|
|
537
|
-
export function
|
|
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
|
|
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
|
|
639
|
+
return stepRecord('flow', 'passed', [], result.scannedFiles)
|
|
550
640
|
}
|
|
551
641
|
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
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
|
-
):
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
|
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
|
-
):
|
|
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
|
|
658
|
-
//
|
|
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
|
|
1304
|
-
if (!await lintDirectory(eslintBin, join(cwd, mod), mod, cacheFor(mod), includes
|
|
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 =
|
|
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(
|
|
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
|
*
|