pipework 0.273.0 → 0.275.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.
@@ -0,0 +1,218 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import { lintIncludeGlobs } from '../../lint/include.js'
7
+ import { resolveLintConfig } from '../../lint/resolver.js'
8
+ import { hasEslintConfig, pipeworkVersion, type LintPopulation } from './check.js'
9
+
10
+ interface EslintApi {
11
+ isPathIgnored(file: string): Promise<boolean>
12
+ calculateConfigForFile(file: string): Promise<unknown>
13
+ }
14
+ type EslintCtor = new (options: Record<string, unknown>) => EslintApi
15
+
16
+ export interface FileCoverage {
17
+ readonly file: string
18
+ readonly covered: boolean
19
+ readonly reason?: 'ignored' | 'no matching configuration'
20
+ }
21
+
22
+ export interface ModuleDigest {
23
+ readonly module: string
24
+ readonly digest: string
25
+ readonly inputs: Record<string, unknown>
26
+ }
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url))
29
+
30
+ function resolveFrom(cwd: string, request: string): string {
31
+ return createRequire(import.meta.url).resolve(request, { paths: [cwd, here] })
32
+ }
33
+
34
+ function packageVersion(cwd: string, name: string): string {
35
+ try {
36
+ const pkg = JSON.parse(readFileSync(resolveFrom(cwd, `${name}/package.json`), 'utf-8')) as { version?: string }
37
+ return pkg.version ?? 'unknown'
38
+ } catch {
39
+ return 'unknown'
40
+ }
41
+ }
42
+
43
+ async function loadEslint(cwd: string): Promise<EslintCtor> {
44
+ const mod = await import(pathToFileURL(resolveFrom(cwd, 'eslint')).href) as { ESLint?: EslintCtor; default?: { ESLint?: EslintCtor } }
45
+ const ctor = mod.ESLint ?? mod.default?.ESLint
46
+ if (ctor === undefined) throw new Error('eslint is installed but does not export ESLint')
47
+ return ctor
48
+ }
49
+
50
+ /**
51
+ * eslint as `check` runs it in `dir`: the directory's own eslint.config.* when it has
52
+ * one, else pipework's resolved preset for that directory (what `--config preset.js`
53
+ * loads, with the same cwd).
54
+ */
55
+ async function eslintFor(ESLint: EslintCtor, dir: string): Promise<EslintApi> {
56
+ if (hasEslintConfig(dir)) return new ESLint({ cwd: dir })
57
+ return new ESLint({ cwd: dir, overrideConfigFile: true, baseConfig: await resolveLintConfig(dir) })
58
+ }
59
+
60
+ /**
61
+ * Whether eslint would lint each candidate file: not ignored, and some config block
62
+ * applies. The answer is eslint's own, asked with the config `check` uses.
63
+ */
64
+ export async function classifyCoverage(cwd: string, population: LintPopulation): Promise<FileCoverage[]> {
65
+ const ESLint = await loadEslint(cwd)
66
+ const instances = new Map<string, EslintApi>()
67
+ const out: FileCoverage[] = []
68
+ for (const { file, dir } of population.candidates) {
69
+ let eslint = instances.get(dir)
70
+ if (eslint === undefined) {
71
+ eslint = await eslintFor(ESLint, dir)
72
+ instances.set(dir, eslint)
73
+ }
74
+ const abs = resolve(cwd, file)
75
+ if (await eslint.isPathIgnored(abs)) {
76
+ out.push({ file, covered: false, reason: 'ignored' })
77
+ } else if (await eslint.calculateConfigForFile(abs) === undefined) {
78
+ out.push({ file, covered: false, reason: 'no matching configuration' })
79
+ } else {
80
+ out.push({ file, covered: true })
81
+ }
82
+ }
83
+ return out
84
+ }
85
+
86
+ function isPlainObject(value: object): boolean {
87
+ const proto = Object.getPrototypeOf(value) as unknown
88
+ return proto === Object.prototype || proto === null
89
+ }
90
+
91
+ function describeNamed(value: object): Record<string, unknown> {
92
+ const meta = (value as { meta?: { name?: unknown; version?: unknown } }).meta
93
+ const rules = (value as { rules?: unknown }).rules
94
+ return {
95
+ object: value.constructor.name,
96
+ ...(typeof meta?.name === 'string' ? { name: meta.name } : {}),
97
+ ...(typeof meta?.version === 'string' ? { version: meta.version } : {}),
98
+ ...(typeof rules === 'object' && rules !== null ? { rules: Object.keys(rules).sort() } : {}),
99
+ }
100
+ }
101
+
102
+ /**
103
+ * A JSON value with sorted keys. A function is its name, a plugin or parser object its
104
+ * `meta` and rule names: never identity, so the same settings hash the same in every run.
105
+ */
106
+ function canonical(value: unknown, seen: Set<object> = new Set()): unknown {
107
+ if (value === undefined) return null
108
+ if (typeof value === 'function') return { function: value.name }
109
+ if (typeof value === 'bigint') return String(value)
110
+ if (typeof value === 'symbol') return value.toString()
111
+ if (value instanceof RegExp) return { regexp: value.toString() }
112
+ if (typeof value !== 'object' || value === null) return value
113
+ if (seen.has(value)) return { cycle: true }
114
+ seen.add(value)
115
+ try {
116
+ if (Array.isArray(value)) return value.map(v => canonical(v, seen))
117
+ if (!isPlainObject(value)) return describeNamed(value)
118
+ const out: Record<string, unknown> = {}
119
+ for (const key of Object.keys(value).sort()) {
120
+ const item = (value as Record<string, unknown>)[key]
121
+ // `plugins` and `parser` hold live objects whose identity is meaningless.
122
+ if ((key === 'parser' || key === 'processor') && typeof item === 'object' && item !== null) out[key] = describeNamed(item)
123
+ else if (key === 'plugins' && typeof item === 'object' && item !== null && !Array.isArray(item)) {
124
+ out[key] = Object.fromEntries(Object.keys(item).sort().map(name =>
125
+ [name, describeNamed((item as Record<string, object>)[name]!)]))
126
+ } else out[key] = canonical(item, seen)
127
+ }
128
+ return out
129
+ } finally {
130
+ seen.delete(value)
131
+ }
132
+ }
133
+
134
+ function sha256(value: unknown): string {
135
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex')
136
+ }
137
+
138
+ function ignoreGlobs(blocks: readonly unknown[]): string[] {
139
+ const globs: string[] = []
140
+ for (const block of blocks) {
141
+ if (typeof block !== 'object' || block === null) continue
142
+ const b = block as { files?: unknown; ignores?: unknown }
143
+ if (b.files === undefined && Array.isArray(b.ignores)) {
144
+ for (const g of b.ignores) if (typeof g === 'string') globs.push(g)
145
+ }
146
+ }
147
+ return globs
148
+ }
149
+
150
+ /**
151
+ * The digest of what decides lint for one module directory: include globs, and the
152
+ * sorted distinct resolved configs (eslint's own `calculateConfigForFile`: rules,
153
+ * language options, settings) over every covered file, so extras, module overrides and
154
+ * per-file blocks are all in and the set does not move when a file joins a region whose
155
+ * config is already present. Global ignores, plugin names and the preset blocks are
156
+ * inputs only where pipework's preset is what runs; a directory with its own
157
+ * eslint.config.* is described by the configs alone. The tsconfig the type-aware rules
158
+ * read is not an input.
159
+ */
160
+ export async function digestModule(
161
+ cwd: string,
162
+ ESLint: EslintCtor,
163
+ label: string,
164
+ dir: string,
165
+ includes: readonly string[] | undefined,
166
+ coveredFiles: readonly string[],
167
+ ): Promise<ModuleDigest> {
168
+ const own = hasEslintConfig(dir)
169
+ const blocks = own ? undefined : await resolveLintConfig(dir)
170
+ const eslint = await eslintFor(ESLint, dir)
171
+ const configs = new Map<string, unknown>()
172
+ for (const file of coveredFiles) {
173
+ // eslint's Config is a class instance with its own toJSON (plugins and parsers by name).
174
+ const resolved = await eslint.calculateConfigForFile(resolve(cwd, file))
175
+ const config = canonical(JSON.parse(JSON.stringify(resolved ?? null)))
176
+ configs.set(JSON.stringify(config), config)
177
+ }
178
+ const inputs = canonical({
179
+ pipework: pipeworkVersion(),
180
+ eslint: packageVersion(cwd, 'eslint'),
181
+ typescriptEslint: packageVersion(cwd, '@typescript-eslint/eslint-plugin'),
182
+ include: lintIncludeGlobs(dir, includes),
183
+ configs: [...configs.keys()].sort().map(k => configs.get(k)),
184
+ ...(blocks === undefined ? {} : {
185
+ ignores: ignoreGlobs(blocks),
186
+ plugins: [...new Set(blocks.flatMap(b => typeof b === 'object' && b !== null && 'plugins' in b
187
+ ? Object.keys((b as { plugins: object }).plugins) : []))].sort(),
188
+ blocks,
189
+ }),
190
+ }) as Record<string, unknown>
191
+ return { module: label, digest: sha256(inputs), inputs }
192
+ }
193
+
194
+ export async function settingsDigests(
195
+ cwd: string,
196
+ population: LintPopulation,
197
+ includes: { for: (mod: string) => readonly string[] | undefined },
198
+ coverage: readonly FileCoverage[],
199
+ rootPass: boolean,
200
+ ): Promise<ModuleDigest[]> {
201
+ const ESLint = await loadEslint(cwd)
202
+ const coveredIn = new Map<string, string[]>()
203
+ const dirOf = new Map(population.candidates.map(c => [c.file, c.dir]))
204
+ for (const c of coverage) {
205
+ const dir = dirOf.get(c.file)
206
+ if (c.covered && dir !== undefined) coveredIn.set(dir, [...(coveredIn.get(dir) ?? []), c.file])
207
+ }
208
+ const out: ModuleDigest[] = []
209
+ const hasRoot = population.modules.some(m => m.path === '.' || m.path === '')
210
+ for (const mod of population.modules) {
211
+ const dir = join(cwd, mod.path)
212
+ out.push(await digestModule(cwd, ESLint, mod.path === '' ? '.' : mod.path, dir, mod.includes, coveredIn.get(dir) ?? []))
213
+ }
214
+ if (rootPass && !hasRoot) {
215
+ out.push(await digestModule(cwd, ESLint, '.', cwd, includes.for('.'), coveredIn.get(cwd) ?? []))
216
+ }
217
+ return out.sort((a, b) => a.module < b.module ? -1 : a.module > b.module ? 1 : 0)
218
+ }
@@ -3,7 +3,8 @@ import { dirname, relative } from 'node:path'
3
3
  import { findConfig } from '../../core/config/discover.js'
4
4
  import { error } from '../output.js'
5
5
  import { builtinConfigArgs, lintPopulation, lintTargets, loadLintIncludes, resolveEslintBin, resolveModulePaths } from './check.js'
6
- import { parseShard } from './shard.js'
6
+ import { parseShard, type Shard } from './shard.js'
7
+ import { classifyCoverage, settingsDigests } from './lint-settings.js'
7
8
 
8
9
  // eslint options that take their value as the next argument. Anything else starting
9
10
  // with `-` is a switch, so the argument after it is a file.
@@ -38,6 +39,7 @@ export function namesFiles(args: readonly string[]): boolean {
38
39
  */
39
40
  export async function lintCommand(cwd: string, args: readonly string[]): Promise<number> {
40
41
  if (args.includes('--list-files')) return listFiles(cwd, args)
42
+ if (args.includes('--settings-digest')) return settingsDigest(cwd, args)
41
43
  const eslintBin = resolveEslintBin(cwd)
42
44
  const configPath = findConfig(cwd)
43
45
  const root = configPath === null ? cwd : dirname(configPath)
@@ -64,32 +66,81 @@ export async function lintCommand(cwd: string, args: readonly string[]): Promise
64
66
  }
65
67
 
66
68
  /**
67
- * `pipework lint --list-files [--shard i/n]`: the repo-relative files `check`'s lint
68
- * pass covers, one per line, read from pipework.config.ts. Nothing is linted.
69
+ * `--list-files` and `--settings-digest` take only `--shard <i>/<n>` and `--format json`.
70
+ * Returns the parsed options, or null after reporting the problem.
69
71
  */
70
- async function listFiles(cwd: string, args: readonly string[]): Promise<number> {
71
- const rest = args.filter(a => a !== '--list-files')
72
- let shard
73
- if (rest.length === 2 && rest[0] === '--shard') {
74
- try {
75
- shard = parseShard(rest[1]!)
76
- } catch (err) {
77
- error(err instanceof Error ? err.message : String(err))
78
- return 1
72
+ function parseReportArgs(flag: string, args: readonly string[]): { shard: Shard | undefined; json: boolean } | null {
73
+ let shard: Shard | undefined
74
+ let json = false
75
+ const rest = args.filter(a => a !== flag)
76
+ const bad = (): null => {
77
+ error(`${flag} takes only --shard <i>/<n> and --format json; got ${rest.join(' ')}. It does not run eslint.`)
78
+ return null
79
+ }
80
+ for (let i = 0; i < rest.length; i++) {
81
+ const arg = rest[i]!
82
+ if (arg === '--shard' && i + 1 < rest.length) {
83
+ try {
84
+ shard = parseShard(rest[++i]!)
85
+ } catch (err) {
86
+ error(err instanceof Error ? err.message : String(err))
87
+ return null
88
+ }
89
+ } else if ((arg === '--format' && rest[i + 1] === 'json') || arg === '--format=json') {
90
+ if (arg === '--format') i++
91
+ json = true
92
+ } else {
93
+ return bad()
79
94
  }
80
- } else if (rest.length > 0) {
81
- error(`--list-files takes only --shard <i>/<n>; got ${rest.join(' ')}. It lists files and does not run eslint.`)
82
- return 1
83
95
  }
96
+ return { shard, json }
97
+ }
84
98
 
99
+ async function reportPopulation(cwd: string, flag: string, args: readonly string[]) {
100
+ const opts = parseReportArgs(flag, args)
101
+ if (opts === null) return null
85
102
  const configPath = findConfig(cwd)
86
103
  const root = configPath === null ? cwd : dirname(configPath)
87
104
  const scope = await resolveModulePaths(root)
88
105
  if (scope.configError !== undefined) {
89
- error(`Cannot list lint files: pipework.config.ts failed to load: ${scope.configError.message}`)
90
- return 1
106
+ error(`Cannot ${flag === '--list-files' ? 'list lint files' : 'digest lint settings'}: pipework.config.ts failed to load: ${scope.configError.message}`)
107
+ return null
91
108
  }
92
- const population = lintPopulation(root, scope.paths, await loadLintIncludes(root), shard)
93
- if (population.files.length > 0) process.stdout.write(population.files.join('\n') + '\n')
109
+ const includes = await loadLintIncludes(root)
110
+ const population = lintPopulation(root, scope.paths, includes, opts.shard)
111
+ return { ...opts, root, includes, population }
112
+ }
113
+
114
+ /**
115
+ * `pipework lint --list-files [--shard i/n] [--format json]`: the repo-relative files
116
+ * `check`'s lint pass covers and eslint will lint, one per line. Files the include globs
117
+ * reach but the lint config ignores are left out; `--format json` lists every candidate
118
+ * with `covered` and, when false, why. Nothing is linted.
119
+ */
120
+ async function listFiles(cwd: string, args: readonly string[]): Promise<number> {
121
+ const ctx = await reportPopulation(cwd, '--list-files', args)
122
+ if (ctx === null) return 1
123
+ const coverage = await classifyCoverage(ctx.root, ctx.population)
124
+ if (ctx.json) {
125
+ process.stdout.write(JSON.stringify(coverage, null, 2) + '\n')
126
+ return 0
127
+ }
128
+ const covered = coverage.filter(c => c.covered).map(c => c.file)
129
+ if (covered.length > 0) process.stdout.write(covered.join('\n') + '\n')
130
+ return 0
131
+ }
132
+
133
+ /**
134
+ * `pipework lint --settings-digest [--shard i/n] [--format json]`: one sha256 per
135
+ * module (the root pass is `.`) of what decides lint there. Nothing is linted.
136
+ */
137
+ async function settingsDigest(cwd: string, args: readonly string[]): Promise<number> {
138
+ const ctx = await reportPopulation(cwd, '--settings-digest', args)
139
+ if (ctx === null) return 1
140
+ const coverage = await classifyCoverage(ctx.root, ctx.population)
141
+ const rootPass = ctx.shard === undefined || ctx.shard.index === 1
142
+ const digests = await settingsDigests(ctx.root, ctx.population, ctx.includes, coverage, rootPass)
143
+ if (ctx.json) process.stdout.write(JSON.stringify(digests, null, 2) + '\n')
144
+ else if (digests.length > 0) process.stdout.write(digests.map(d => `${d.module} ${d.digest}`).join('\n') + '\n')
94
145
  return 0
95
146
  }
package/src/cli/index.ts CHANGED
@@ -24,7 +24,8 @@ ${bold('Commands:')}
24
24
  cardinality [files] @downstream cardinality rules alone (same options)
25
25
  startup Startup validation alone: pipework.config.ts loaded, no database
26
26
  lint [eslint args] Lint the current module (the workspace at its root) with check's config; every argument goes to eslint
27
- lint --list-files [--shard i/n]: print the files check's lint covers, lint nothing
27
+ lint --list-files [--shard i/n] [--format json]: print the files eslint will lint (json: every candidate, covered or not), lint nothing
28
+ lint --settings-digest [--shard i/n] [--format json]: print a digest per module of the resolved lint settings, lint nothing
28
29
  codemod Fill missing @downstream cardinality across the corpus
29
30
  db Database lifecycle: setup, teardown, reset
30
31
  alerts unit-fault Record an abnormal systemd stop (ExecStopPost=pipework alerts unit-fault --unit %n)
@@ -93,6 +94,7 @@ ${bold('Examples:')}
93
94
  pipework lint # the module you are in, exactly as check lints it
94
95
  pipework lint --format json --output-file r.json src/a.ts # only src/a.ts
95
96
  pipework lint --list-files --shard 1/4 # the files check's lint pass covers for part 1 of 4, from pipework.config.ts
97
+ pipework lint --settings-digest --format json # per-module digest of what decides lint, for a lint-result cache key
96
98
  pipework codemod
97
99
  pipework codemod --dry-run
98
100
  pipework alerts unit-fault --unit subseam-core.service # reads $SERVICE_RESULT, $EXIT_CODE, $EXIT_STATUS
@@ -447,6 +447,16 @@ export function createServer(instance: Manifold, config: ServerConfig = {}): Pip
447
447
  if (handler.meta.route?.cancelOnDisconnect !== false) {
448
448
  reply.raw.once('close', onClose)
449
449
  reply.raw.once('finish', () => { reply.raw.removeListener('close', onClose) })
450
+ // An injected request has no socket: light-my-request keeps the caller's signal on the raw request, and its abort is the disconnect.
451
+ const injected = (req.raw as { _lightMyRequest?: { signal?: AbortSignal } })._lightMyRequest?.signal
452
+ if (injected !== undefined) {
453
+ const onInjectAbort = (): void => { onClose() }
454
+ if (injected.aborted) onInjectAbort()
455
+ else {
456
+ injected.addEventListener('abort', onInjectAbort, { once: true })
457
+ reply.raw.once('finish', () => { injected.removeEventListener('abort', onInjectAbort) })
458
+ }
459
+ }
450
460
  }
451
461
  // Ends the upload stream and closes the connection if the body was left unread.
452
462
  const finishUpload = (failed: boolean): void => {
@@ -726,6 +736,7 @@ export function createServer(instance: Manifold, config: ServerConfig = {}): Pip
726
736
  payload: opts.payload as string,
727
737
  query: opts.query as Record<string, string>,
728
738
  ...(opts.remoteAddress !== undefined ? { remoteAddress: opts.remoteAddress } : {}),
739
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
729
740
  })
730
741
  const r = response as unknown as { statusCode: number; headers: Record<string, string | number | string[] | undefined>; body: string; rawPayload: Buffer; json(): unknown }
731
742
  return { statusCode: r.statusCode, headers: r.headers, body: r.body, rawPayload: r.rawPayload, json: () => r.json() }
@@ -140,6 +140,8 @@ export interface InjectOptions {
140
140
  query?: Record<string, string | readonly string[]>
141
141
  /** The socket address the request appears to come from (default `127.0.0.1`). Lets a test exercise `request.ip` and `trustProxy`. */
142
142
  remoteAddress?: string
143
+ /** Aborting it while the handler runs is a client disconnect: the request's signal aborts with `RequestAbortedError` and its transaction rolls back (a `cancelOnDisconnect: false` route runs on). */
144
+ signal?: AbortSignal
143
145
  }
144
146
 
145
147
  /** Response from server.inject() — statusCode, headers, body, rawPayload, json() helper. */