@wringer/core 0.1.1 → 0.2.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/README.md CHANGED
@@ -39,6 +39,7 @@ export const registry = createWringer({ name: 'shop' })
39
39
  }),
40
40
  })
41
41
  .feature({ name: 'checkout', summary: 'Pay for the cart', paths: ['src/checkout/**'] })
42
+ .ignore('docs/**', '**/*.md', '**/*.test.ts')
42
43
  .build();
43
44
  ```
44
45
 
@@ -53,22 +54,28 @@ own file.
53
54
  - Read-only targets: each driver client and exec refuses a write there with `read-only` and the
54
55
  target's hint. A target's driver config lists the writes it grants.
55
56
  - One supervisor process per run, which owns the app and every driver service.
56
- - The built-in commands, from `up` and `doctor` to `done`, `report` and `gate`.
57
+ - The built-in commands, from `up` and `doctor` to `done`, `report` and `gate`. `gate` fails a PR
58
+ when a changed feature lacks a verdict at the current fingerprint, and when a changed path matches
59
+ neither a feature's `paths` nor an `.ignore` glob. `report` lists those unmapped paths.
60
+ - The ignore list: `.ignore(...globs)` names the paths with no runtime effect, such as docs and
61
+ tests. Any path an ignore glob matches counts as no change, even when a feature's `paths` match it
62
+ too. The gate skips it and the fingerprint leaves it out, so a README edit keeps each verdict
63
+ fresh. Feature `paths` and ignore globs both match with `Bun.Glob`.
57
64
  - The envelope's zod schema, and the JSON Schema built from it.
58
65
  - A closed set of error codes, each mapped to one exit code.
59
66
  - Run state on disk: `state.json` holds pointers, and each run's `log.ndjson` holds its events.
60
- - The fingerprint: `git patch-id --verbatim` of the working tree against the merge base, which stays
61
- the same across a commit and across a clean rebase that leaves the 3 lines around each change as
62
- they were. A rebase that changes those lines changes the fingerprint, because the hash keeps the
63
- diff's context. It needs git 2.39 or later. Only the tracked files and git's defaults shape it:
64
- the config, attributes and ignore files of the clone, the user and the system never apply, so the
65
- same change gets the same fingerprint in any clone. A clone whose config converts a file as git
66
- adds it, such as `core.autocrlf` or a clean filter, can get another fingerprint once the change is
67
- committed; the verdict then reads as stale, never as fresh. A sparse checkout, a linked worktree,
68
- a shallow or partial clone and a detached HEAD get the fingerprint of the same change in a full
69
- clone, and a file on disk that the index marks skip-worktree counts as any other. A repo with no
70
- commit, a shallow clone that lacks the merge base, and a submodule that holds work it has not
71
- committed fail with an `environment` error.
67
+ - The fingerprint: `git patch-id --verbatim` of the working tree against the merge base, less the
68
+ state folder and the ignored paths, which stays the same across a commit and across a clean rebase
69
+ that leaves the 3 lines around each change as they were. A rebase that changes those lines changes
70
+ the fingerprint, because the hash keeps the diff's context. It needs git 2.39 or later. Only the
71
+ tracked files and git's defaults shape it: the config, attributes and ignore files of the clone,
72
+ the user and the system never apply, so the same change gets the same fingerprint in any clone. A
73
+ clone whose config converts a file as git adds it, such as `core.autocrlf` or a clean filter, can
74
+ get another fingerprint once the change is committed; the verdict then reads as stale, never as
75
+ fresh. A sparse checkout, a linked worktree, a shallow or partial clone and a detached HEAD get
76
+ the fingerprint of the same change in a full clone, and a file on disk that the index marks
77
+ skip-worktree counts as any other. A repo with no commit, a shallow clone that lacks the merge
78
+ base, and a submodule that holds work it has not committed fail with an `environment` error.
72
79
  - Two hosts for exec: `local()`, and `ssh()` with an argv prefix in layers, quoted once per shell
73
80
  layer. Each host also follows a long-running command, such as `tail -F`, line by line.
74
81
 
@@ -0,0 +1,8 @@
1
+ import type { Wringer } from './types.ts';
2
+ export interface ChangeRequest {
3
+ readonly cwd: string;
4
+ readonly base: string;
5
+ readonly exclude: readonly string[];
6
+ readonly ignore: readonly string[];
7
+ }
8
+ export declare function buildChangeRequest(wringer: Pick<Wringer, 'stateDir' | 'ignore'>, cwd: string, base: string): ChangeRequest;
@@ -10,6 +10,7 @@ export interface WringerBuilder<Ds extends AnyDrivers> {
10
10
  readonly action: <Input, Data>(action: ActionDefinition<Input, Data, Ds>) => WringerBuilder<Ds>;
11
11
  readonly check: <Input, Data>(check: CheckDefinition<Input, Data, Ds>) => WringerBuilder<Ds>;
12
12
  readonly feature: (...features: readonly FeatureDefinition[]) => WringerBuilder<Ds>;
13
+ readonly ignore: (...globs: readonly string[]) => WringerBuilder<Ds>;
13
14
  readonly hook: <Name extends keyof HookHandlers>(name: Name, handler: HookHandlers[Name]) => WringerBuilder<Ds>;
14
15
  readonly build: () => Wringer;
15
16
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { bindDrivers } from './bind-drivers.ts';
2
2
  export type { BoundHelpers } from './bind-drivers.ts';
3
+ export { buildBuiltinCommand } from './build-commands.ts';
3
4
  export { buildEnvelope } from './build-envelope.ts';
4
5
  export type { EnvelopeInput } from './build-envelope.ts';
5
6
  export { buildEnvelopeJSONSchema } from './build-envelope-json-schema.ts';
@@ -16,6 +17,7 @@ export type { StateFileSystem } from './create-node-state-file-system.ts';
16
17
  export { createWringer, defaultStateDir } from './create-wringer.ts';
17
18
  export type { WringerBuilder, WringerOptions } from './create-wringer.ts';
18
19
  export { defineDriver } from './define-driver.ts';
20
+ export { prepareArgs } from './prepare-args.ts';
19
21
  export { findOwnValue } from './find-own-value.ts';
20
22
  export type { DriverDefinition, DriverFactory } from './define-driver.ts';
21
23
  export { dataCapBytes } from './cap-data.ts';
@@ -0,0 +1 @@
1
+ export declare function isGlobMatch(path: string, globs: readonly string[]): boolean;
@@ -1,2 +1,2 @@
1
1
  import { WringerError } from './wringer-error.ts';
2
- export declare function pickSubmoduleRefusal(statusEntries: Uint8Array): WringerError | null;
2
+ export declare function pickSubmoduleRefusal(statusEntries: Uint8Array, ignore: readonly string[]): WringerError | null;
@@ -1,7 +1,3 @@
1
+ import type { ChangeRequest } from './build-change-request.ts';
1
2
  import type { FingerprintHost } from './read-fingerprint.ts';
2
- export interface ChangedPathsRequest {
3
- readonly cwd: string;
4
- readonly base: string;
5
- readonly exclude: readonly string[];
6
- }
7
- export declare function readChangedPaths(host: Pick<FingerprintHost, 'runGit'>, request: ChangedPathsRequest): Promise<readonly string[]>;
3
+ export declare function readChangedPaths(host: Pick<FingerprintHost, 'runGit' | 'runGitBytes'>, request: ChangeRequest): Promise<readonly string[]>;
@@ -1,3 +1,4 @@
1
+ import type { ChangeRequest } from './build-change-request.ts';
1
2
  import type { GitRequest } from './run-git.ts';
2
3
  import type { ProcessResult } from './run-process.ts';
3
4
  export interface FingerprintHost {
@@ -5,9 +6,5 @@ export interface FingerprintHost {
5
6
  readonly runGitBytes: (request: GitRequest) => Promise<Uint8Array>;
6
7
  readonly removeDirectory: (path: string) => Promise<void>;
7
8
  }
8
- export interface FingerprintRequest {
9
- readonly cwd: string;
10
- readonly base: string;
11
- readonly exclude: readonly string[];
12
- }
9
+ export type FingerprintRequest = ChangeRequest;
13
10
  export declare function readFingerprint(host: FingerprintHost, request: FingerprintRequest): Promise<string>;
@@ -46,6 +46,7 @@ export declare const reportSchema: z.ZodObject<{
46
46
  }, z.core.$strict>>;
47
47
  }, z.core.$strict>>;
48
48
  }, z.core.$strict>>;
49
+ unmapped: z.ZodDefault<z.ZodArray<z.ZodString>>;
49
50
  }, z.core.$strict>;
50
51
  export type Report = z.infer<typeof reportSchema>;
51
52
  export {};
package/dist/types.d.ts CHANGED
@@ -317,6 +317,7 @@ export interface Wringer {
317
317
  readonly commands: ReadonlyMap<string, RegisteredCommand>;
318
318
  readonly checks: readonly RunnableRepoCheck[];
319
319
  readonly features: readonly FeatureDefinition[];
320
+ readonly ignore: readonly string[];
320
321
  readonly hooks: Partial<HookHandlers>;
321
322
  }
322
323
  export interface SupervisorStartRequest {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wringer/core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The core of wringer: the envelope, the registry, the fence, the run state and the evidence",
5
5
  "homepage": "https://github.com/zgeoff/wringer/tree/main/packages/core#readme",
6
6
  "bugs": "https://github.com/zgeoff/wringer/issues",
@@ -35,13 +35,13 @@
35
35
  "schema": "bun scripts/write-envelope-schema.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@zgeoff/std": "0.4.2",
38
+ "@zgeoff/std": "0.4.3",
39
39
  "ts-pattern": "5.9.2",
40
40
  "xstate": "5.33.3",
41
41
  "zod": "4.6.7"
42
42
  },
43
43
  "devDependencies": {
44
- "@zgeoff/testing": "0.2.1",
44
+ "@zgeoff/testing": "0.3.0",
45
45
  "ajv": "8.20.0",
46
46
  "tiny-invariant": "1.3.3"
47
47
  },
@@ -0,0 +1,18 @@
1
+ import type { Wringer } from './types.ts';
2
+
3
+ export interface ChangeRequest {
4
+ readonly cwd: string;
5
+ readonly base: string;
6
+ readonly exclude: readonly string[];
7
+ readonly ignore: readonly string[];
8
+ }
9
+
10
+ // Every read of the fingerprint or the changed paths leaves out the same paths, so a verdict, a run
11
+ // and the gate agree on what the branch changes.
12
+ export function buildChangeRequest(
13
+ wringer: Pick<Wringer, 'stateDir' | 'ignore'>,
14
+ cwd: string,
15
+ base: string,
16
+ ): ChangeRequest {
17
+ return { cwd, base, exclude: [wringer.stateDir], ignore: wringer.ignore };
18
+ }
@@ -1,3 +1,4 @@
1
+ import { isGlobMatch } from './is-glob-match.ts';
1
2
  import type { FeatureStatus, Report, Verdict } from './report-schema.ts';
2
3
  import type { FeatureDefinition } from './types.ts';
3
4
 
@@ -10,13 +11,11 @@ export interface ReportInput {
10
11
  }
11
12
 
12
13
  // Each feature whose paths the branch changes needs a verdict at the current fingerprint. A feature
13
- // the branch leaves alone is `unchanged`, whatever its verdict.
14
+ // the branch leaves alone is `unchanged`, whatever its verdict. A changed path no feature maps is
15
+ // unmapped, and leaves the report incomplete.
14
16
  export function buildReport(input: ReportInput): Report {
15
17
  const features = input.features.map((feature) => {
16
- const changed = input.changedPaths.some((path) =>
17
- feature.paths.some((pattern) => new Bun.Glob(pattern).match(path)),
18
- );
19
-
18
+ const changed = input.changedPaths.some((path) => isGlobMatch(path, feature.paths));
20
19
  const verdict = input.verdicts.get(feature.name) ?? null;
21
20
 
22
21
  return {
@@ -27,14 +26,19 @@ export function buildReport(input: ReportInput): Report {
27
26
  };
28
27
  });
29
28
 
29
+ const unmapped = input.changedPaths.filter(
30
+ (path) => !input.features.some((feature) => isGlobMatch(path, feature.paths)),
31
+ );
32
+
30
33
  return {
31
34
  version: 1,
32
35
  base: input.base,
33
36
  fingerprint: input.fingerprint,
34
- complete: features.every(
35
- (feature) => feature.status === 'verified' || feature.status === 'unchanged',
36
- ),
37
+ complete:
38
+ unmapped.length === 0 &&
39
+ features.every((feature) => feature.status === 'verified' || feature.status === 'unchanged'),
37
40
  features,
41
+ unmapped,
38
42
  };
39
43
  }
40
44
 
@@ -1,5 +1,6 @@
1
1
  import { isAbsolute, join } from 'node:path';
2
2
  import { z } from 'zod';
3
+ import { buildChangeRequest } from '../build-change-request.ts';
3
4
  import { buildBuiltinCommand } from '../build-commands.ts';
4
5
  import { buildReport } from '../build-report.ts';
5
6
  import type { Evidence } from '../envelope.ts';
@@ -38,6 +39,7 @@ const gateFailureSchema = z.strictObject({ name: z.string(), reason: z.string()
38
39
  const gateDataSchema = z.strictObject({
39
40
  fingerprint: z.string(),
40
41
  failing: z.array(gateFailureSchema),
42
+ unmapped: z.array(z.string()),
41
43
  });
42
44
 
43
45
  type GateData = z.infer<typeof gateDataSchema>;
@@ -47,7 +49,8 @@ type GateData = z.infer<typeof gateDataSchema>;
47
49
  export function buildVerdictCommands(): readonly RegisteredCommand[] {
48
50
  const done: BuiltinCommandSpec<DoneArgs, Verdict> = {
49
51
  path: [doneCommand],
50
- summary: 'Record a verdict for a feature from the drives since the doctor passed',
52
+ summary:
53
+ 'Record a verdict for a feature from its commands that touched the app since the doctor passed',
51
54
  args: doneArgsSchema,
52
55
  data: verdictSchema,
53
56
  effect: 'read',
@@ -75,7 +78,7 @@ export function buildVerdictCommands(): readonly RegisteredCommand[] {
75
78
  const gate: BuiltinCommandSpec<GateArgs, GateData> = {
76
79
  path: ['gate'],
77
80
  summary:
78
- 'Fail when a changed feature lacks a verdict at the current fingerprint in the PR body',
81
+ 'Fail when a changed feature lacks a verdict at the current fingerprint in the PR body, or a changed path maps to no feature',
79
82
  args: gateArgsSchema,
80
83
  data: gateDataSchema,
81
84
  effect: 'read',
@@ -120,7 +123,7 @@ async function recordVerdict(args: DoneArgs, ctx: BuiltinContext): Promise<Verdi
120
123
 
121
124
  if (drives.length === 0) {
122
125
  throw new WringerError('check-failed', {
123
- message: `No drive of ${feature} passed on run ${ctx.run.runID} since the doctor passed.`,
126
+ message: `No command of ${feature} that touches the app passed on run ${ctx.run.runID} since the doctor passed.`,
124
127
  hint: `Drive the feature with \`--feature ${feature}\`, then run \`done\` again.`,
125
128
  });
126
129
  }
@@ -140,11 +143,10 @@ async function recordVerdict(args: DoneArgs, ctx: BuiltinContext): Promise<Verdi
140
143
  }
141
144
 
142
145
  function readCurrentFingerprint(ctx: BuiltinContext, base: string): Promise<string> {
143
- return readFingerprint(ctx.session.repoHost, {
144
- cwd: ctx.session.root,
145
- base,
146
- exclude: [ctx.wringer.stateDir],
147
- });
146
+ return readFingerprint(
147
+ ctx.session.repoHost,
148
+ buildChangeRequest(ctx.wringer, ctx.session.root, base),
149
+ );
148
150
  }
149
151
 
150
152
  async function readReport(ctx: BuiltinContext, givenBase: string | undefined): Promise<Report> {
@@ -152,11 +154,7 @@ async function readReport(ctx: BuiltinContext, givenBase: string | undefined): P
152
154
 
153
155
  const [fingerprint, changedPaths, verdicts] = await Promise.all([
154
156
  readCurrentFingerprint(ctx, base),
155
- readChangedPaths(ctx.session.repoHost, {
156
- cwd: ctx.session.root,
157
- base,
158
- exclude: [ctx.wringer.stateDir],
159
- }),
157
+ readChangedPaths(ctx.session.repoHost, buildChangeRequest(ctx.wringer, ctx.session.root, base)),
160
158
  readVerdicts(ctx.state, ctx.session.repoHost, ctx.session.root),
161
159
  ]);
162
160
 
@@ -211,11 +209,10 @@ async function checkGate(args: GateArgs, ctx: BuiltinContext): Promise<GateData>
211
209
 
212
210
  const [fingerprint, changedPaths] = await Promise.all([
213
211
  readCurrentFingerprint(ctx, args.base),
214
- readChangedPaths(ctx.session.repoHost, {
215
- cwd: ctx.session.root,
216
- base: args.base,
217
- exclude: [ctx.wringer.stateDir],
218
- }),
212
+ readChangedPaths(
213
+ ctx.session.repoHost,
214
+ buildChangeRequest(ctx.wringer, ctx.session.root, args.base),
215
+ ),
219
216
  ]);
220
217
 
221
218
  const claimed = block.ok
@@ -251,15 +248,44 @@ async function checkGate(args: GateArgs, ctx: BuiltinContext): Promise<GateData>
251
248
  return [];
252
249
  });
253
250
 
254
- const data = { fingerprint, failing };
251
+ const data = { fingerprint, failing, unmapped: computed.unmapped };
255
252
 
256
- if (failing.length > 0) {
253
+ if (failing.length > 0 || computed.unmapped.length > 0) {
257
254
  throw new WringerError('check-failed', {
258
- message: `${failing.length} changed feature(s) lack a verdict at the current fingerprint: ${failing.map((feature) => feature.name).join(', ')}.`,
259
- hint: 'Drive each feature, run `done` and `report --human`, and update the PR body.',
255
+ message: formatGateFailure(failing, computed.unmapped),
256
+ hint: pickGateHint(failing, computed.unmapped),
260
257
  data,
261
258
  });
262
259
  }
263
260
 
264
261
  return data;
265
262
  }
263
+
264
+ function formatGateFailure(
265
+ failing: readonly { readonly name: string }[],
266
+ unmapped: readonly string[],
267
+ ): string {
268
+ const lines = [
269
+ failing.length > 0
270
+ ? `${failing.length} changed feature(s) lack a verdict at the current fingerprint: ${failing.map((feature) => feature.name).join(', ')}.`
271
+ : null,
272
+ unmapped.length > 0
273
+ ? `${unmapped.length} changed path(s) map to no feature: ${unmapped.join(', ')}.`
274
+ : null,
275
+ ];
276
+
277
+ return lines.filter((line) => line !== null).join(' ');
278
+ }
279
+
280
+ const verdictHint = 'Drive each feature, run `done` and `report --human`, and update the PR body.';
281
+
282
+ const unmappedHint =
283
+ "Add each unmapped path to a feature's `paths`, or to the registry's ignore list when it has no runtime effect.";
284
+
285
+ function pickGateHint(failing: readonly unknown[], unmapped: readonly string[]): string {
286
+ if (unmapped.length === 0) {
287
+ return verdictHint;
288
+ }
289
+
290
+ return failing.length === 0 ? unmappedHint : `${unmappedHint} ${verdictHint}`;
291
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { buildChangeRequest } from '../build-change-request.ts';
2
3
  import type { RunView } from '../build-run-view.ts';
3
4
  import { findOwnValue } from '../find-own-value.ts';
4
5
  import { readDefaultBase } from '../read-default-base.ts';
@@ -52,11 +53,10 @@ export function startRun(ctx: BuiltinContext, request: StartRunRequest): Promise
52
53
 
53
54
  const base = await readDefaultBase(ctx.session.repoHost, ctx.session.root);
54
55
 
55
- const fingerprint = await readFingerprint(ctx.session.repoHost, {
56
- cwd: ctx.session.root,
57
- base,
58
- exclude: [ctx.wringer.stateDir],
59
- });
56
+ const fingerprint = await readFingerprint(
57
+ ctx.session.repoHost,
58
+ buildChangeRequest(ctx.wringer, ctx.session.root, base),
59
+ );
60
60
 
61
61
  // The caller has its timeout already once the deadline passes, so no run starts after it.
62
62
  if (ctx.signal.aborted) {
@@ -33,6 +33,7 @@ export interface WringerBuilder<Ds extends AnyDrivers> {
33
33
  readonly action: <Input, Data>(action: ActionDefinition<Input, Data, Ds>) => WringerBuilder<Ds>;
34
34
  readonly check: <Input, Data>(check: CheckDefinition<Input, Data, Ds>) => WringerBuilder<Ds>;
35
35
  readonly feature: (...features: readonly FeatureDefinition[]) => WringerBuilder<Ds>;
36
+ readonly ignore: (...globs: readonly string[]) => WringerBuilder<Ds>;
36
37
  readonly hook: <Name extends keyof HookHandlers>(
37
38
  name: Name,
38
39
  handler: HookHandlers[Name],
@@ -50,6 +51,7 @@ export function createWringer(options: WringerOptions): WringerBuilder<readonly
50
51
  commands: [],
51
52
  checks: [],
52
53
  features: [],
54
+ ignore: [],
53
55
  hooks: {},
54
56
  });
55
57
  }
@@ -61,6 +63,7 @@ interface Parts {
61
63
  readonly commands: readonly ((driverNames: readonly string[]) => RegisteredCommand)[];
62
64
  readonly checks: readonly ((driverNames: readonly string[]) => RunnableRepoCheck)[];
63
65
  readonly features: readonly FeatureDefinition[];
66
+ readonly ignore: readonly string[];
64
67
  readonly hooks: Partial<HookHandlers>;
65
68
  }
66
69
 
@@ -83,6 +86,7 @@ function makeBuilder<Ds extends AnyDrivers>(parts: Parts): WringerBuilder<Ds> {
83
86
  check: (check) =>
84
87
  makeBuilder({ ...parts, checks: [...parts.checks, (names) => buildRepoCheck(check, names)] }),
85
88
  feature: (...features) => makeBuilder({ ...parts, features: [...parts.features, ...features] }),
89
+ ignore: (...globs) => makeBuilder({ ...parts, ignore: [...parts.ignore, ...globs] }),
86
90
  hook: (name, handler) => makeBuilder({ ...parts, hooks: { ...parts.hooks, [name]: handler } }),
87
91
  build: () => buildWringer(parts),
88
92
  };
@@ -147,6 +151,7 @@ function buildWringer(parts: Parts): Wringer {
147
151
  ),
148
152
  checks: Object.freeze(parts.checks.map((buildEntry) => buildEntry(driverNames))),
149
153
  features: Object.freeze([...parts.features]),
154
+ ignore: Object.freeze([...parts.ignore]),
150
155
  hooks: Object.freeze({ ...parts.hooks }),
151
156
  });
152
157
  }
package/src/dispatch.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { match } from 'ts-pattern';
3
+ import { buildChangeRequest } from './build-change-request.ts';
3
4
  import { buildEnvelope } from './build-envelope.ts';
4
5
  import type { RunView } from './build-run-view.ts';
5
6
  import { buildRunPaths } from './build-state-paths.ts';
@@ -367,11 +368,10 @@ async function runCommand(request: CommandRequest): Promise<CommandOutcome> {
367
368
  async function probeFingerprintChanged(request: CommandRequest): Promise<boolean> {
368
369
  const base = await readDefaultBase(request.session.repoHost, request.session.root);
369
370
 
370
- const fingerprint = await readFingerprint(request.session.repoHost, {
371
- cwd: request.session.root,
372
- base,
373
- exclude: [request.wringer.stateDir],
374
- });
371
+ const fingerprint = await readFingerprint(
372
+ request.session.repoHost,
373
+ buildChangeRequest(request.wringer, request.session.root, base),
374
+ );
375
375
 
376
376
  return fingerprint !== request.context.run?.fingerprint;
377
377
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { bindDrivers } from './bind-drivers.ts';
2
2
  export type { BoundHelpers } from './bind-drivers.ts';
3
+ export { buildBuiltinCommand } from './build-commands.ts';
3
4
  export { buildEnvelope } from './build-envelope.ts';
4
5
  export type { EnvelopeInput } from './build-envelope.ts';
5
6
  export { buildEnvelopeJSONSchema } from './build-envelope-json-schema.ts';
@@ -16,6 +17,7 @@ export type { StateFileSystem } from './create-node-state-file-system.ts';
16
17
  export { createWringer, defaultStateDir } from './create-wringer.ts';
17
18
  export type { WringerBuilder, WringerOptions } from './create-wringer.ts';
18
19
  export { defineDriver } from './define-driver.ts';
20
+ export { prepareArgs } from './prepare-args.ts';
19
21
  export { findOwnValue } from './find-own-value.ts';
20
22
  export type { DriverDefinition, DriverFactory } from './define-driver.ts';
21
23
  export { dataCapBytes } from './cap-data.ts';
@@ -0,0 +1,5 @@
1
+ // Every glob matches through Bun's matcher, never as a git pathspec, whose `*` and `**` differ, so
2
+ // the changed paths, the fingerprint and the report agree on which paths a glob covers.
3
+ export function isGlobMatch(path: string, globs: readonly string[]): boolean {
4
+ return globs.some((glob) => new Bun.Glob(glob).match(path));
5
+ }
@@ -1,10 +1,16 @@
1
+ import { isGlobMatch } from './is-glob-match.ts';
1
2
  import { WringerError } from './wringer-error.ts';
2
3
 
3
4
  // `status --porcelain=v2` gives each submodule a field `S<c><m><u>`: `m` is `M` when its tracked
4
5
  // files changed and `u` is `U` when it holds untracked files. A moved commit alone reaches the
5
6
  // fingerprint through the gitlink, but work inside a submodule never does.
6
- export function pickSubmoduleRefusal(statusEntries: Uint8Array): WringerError | null {
7
- const dirty = collectDirtySubmodules(Buffer.from(statusEntries).toString('utf8'));
7
+ export function pickSubmoduleRefusal(
8
+ statusEntries: Uint8Array,
9
+ ignore: readonly string[],
10
+ ): WringerError | null {
11
+ const dirty = collectDirtySubmodules(Buffer.from(statusEntries).toString('utf8')).filter(
12
+ (path) => !isGlobMatch(path, ignore),
13
+ );
8
14
 
9
15
  if (dirty.length === 0) {
10
16
  return null;
@@ -1,16 +1,12 @@
1
+ import type { ChangeRequest } from './build-change-request.ts';
2
+ import { isGlobMatch } from './is-glob-match.ts';
1
3
  import type { FingerprintHost } from './read-fingerprint.ts';
2
4
 
3
- export interface ChangedPathsRequest {
4
- readonly cwd: string;
5
- readonly base: string;
6
- readonly exclude: readonly string[];
7
- }
8
-
9
5
  // The paths the branch changes against its merge base: committed, staged and unstaged edits, and
10
- // untracked files that git does not ignore.
6
+ // untracked files that git does not ignore. A path an ignore glob matches is left out.
11
7
  export async function readChangedPaths(
12
- host: Pick<FingerprintHost, 'runGit'>,
13
- request: ChangedPathsRequest,
8
+ host: Pick<FingerprintHost, 'runGit' | 'runGitBytes'>,
9
+ request: ChangeRequest,
14
10
  ): Promise<readonly string[]> {
15
11
  const mergeBaseResult = await host.runGit({
16
12
  cwd: request.cwd,
@@ -21,19 +17,41 @@ export async function readChangedPaths(
21
17
  const excludes = request.exclude.map((path) => `:(exclude)${path}`);
22
18
 
23
19
  // `-z` gives each path as it is: without it, git quotes a path with a tab, a newline or a
24
- // non-ASCII letter, and the quoted form matches no feature's glob.
20
+ // non-ASCII letter, and the quoted form matches no feature's glob. Each path is decoded on its
21
+ // own, as the fingerprint decodes it, so a leading byte-order mark stays part of the name.
25
22
  const [tracked, untracked] = await Promise.all([
26
- host.runGit({
23
+ host.runGitBytes({
27
24
  cwd: request.cwd,
28
25
  args: ['diff', '--name-only', '-z', '--no-renames', mergeBase, '--', '.', ...excludes],
29
26
  }),
30
- host.runGit({
27
+ host.runGitBytes({
31
28
  cwd: request.cwd,
32
29
  args: ['ls-files', '-z', '--others', '--exclude-standard', '--', '.', ...excludes],
33
30
  }),
34
31
  ]);
35
32
 
36
- const paths = `${tracked.stdout}\0${untracked.stdout}`.split('\0').filter((path) => path !== '');
33
+ const paths = [...splitNames(tracked), ...splitNames(untracked)].filter(
34
+ (path) => !isGlobMatch(path, request.ignore),
35
+ );
37
36
 
38
37
  return [...new Set(paths)].toSorted();
39
38
  }
39
+
40
+ function splitNames(output: Uint8Array): readonly string[] {
41
+ const names: string[] = [];
42
+ const bytes = Buffer.from(output);
43
+ let start = 0;
44
+
45
+ while (start < bytes.length) {
46
+ const end = bytes.indexOf(0, start);
47
+ const stop = end === -1 ? bytes.length : end;
48
+
49
+ if (stop > start) {
50
+ names.push(bytes.subarray(start, stop).toString('utf8'));
51
+ }
52
+
53
+ start = stop + 1;
54
+ }
55
+
56
+ return names;
57
+ }
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { join, resolve } from 'node:path';
3
+ import type { ChangeRequest } from './build-change-request.ts';
3
4
  import { buildPromisedObjectReads } from './build-promised-object-reads.ts';
4
5
  import { buildSkipWorktreeMarks } from './build-skip-worktree-marks.ts';
5
6
  import { buildSubmoduleAdds } from './build-submodule-adds.ts';
7
+ import { isGlobMatch } from './is-glob-match.ts';
6
8
  import { parsePatchID } from './parse-patch-id.ts';
7
9
  import { pickMergeBaseFailure } from './pick-merge-base-failure.ts';
8
10
  import { pickMergeBaseSteps } from './pick-merge-base-steps.ts';
@@ -17,11 +19,9 @@ export interface FingerprintHost {
17
19
  readonly removeDirectory: (path: string) => Promise<void>;
18
20
  }
19
21
 
20
- export interface FingerprintRequest {
21
- readonly cwd: string;
22
- readonly base: string;
23
- readonly exclude: readonly string[];
24
- }
22
+ // `ignore` holds globs of paths with no runtime effect: a change to a path one matches leaves the
23
+ // fingerprint alone.
24
+ export type FingerprintRequest = ChangeRequest;
25
25
 
26
26
  const pinnedDiffOptions = [
27
27
  '--unified=3',
@@ -46,7 +46,7 @@ export async function readFingerprint(
46
46
  const mergeBase = await readMergeBase(host, root, request.base);
47
47
 
48
48
  await readPromisedObjects(host, root, mergeBase);
49
- await readSubmoduleWork(host, root);
49
+ await readSubmoduleWork(host, root, request.ignore);
50
50
 
51
51
  const gitDir = await readGitPath(host, root, `wringer-${randomUUID()}`);
52
52
 
@@ -75,6 +75,7 @@ export async function readFingerprint(
75
75
  });
76
76
 
77
77
  await addSubmodules(host, root, env);
78
+ await restoreIgnoredPaths(host, { root, env, mergeBase, ignore: request.ignore });
78
79
 
79
80
  // The exclusions apply to the diff, not to `add`: `add` refuses an ignored path in a pathspec,
80
81
  // and a committed change to an excluded path reaches the index from the repo's own anyway.
@@ -156,7 +157,11 @@ async function readPromisedObjects(
156
157
 
157
158
  // `--no-optional-locks` keeps status from writing the repo's own index, and `-uno` skips the
158
159
  // repo's own untracked files, which `add --all` counts anyway.
159
- async function readSubmoduleWork(host: FingerprintHost, root: string): Promise<void> {
160
+ async function readSubmoduleWork(
161
+ host: FingerprintHost,
162
+ root: string,
163
+ ignore: readonly string[],
164
+ ): Promise<void> {
160
165
  const status = await host.runGitBytes({
161
166
  cwd: root,
162
167
  args: [
@@ -169,7 +174,7 @@ async function readSubmoduleWork(host: FingerprintHost, root: string): Promise<v
169
174
  ],
170
175
  });
171
176
 
172
- const refusal = pickSubmoduleRefusal(status);
177
+ const refusal = pickSubmoduleRefusal(status, ignore);
173
178
 
174
179
  if (refusal !== null) {
175
180
  throw refusal;
@@ -269,3 +274,95 @@ async function addSubmodules(
269
274
  await host.runGit(request);
270
275
  }
271
276
  }
277
+
278
+ interface IgnoredPathsRequest {
279
+ readonly root: string;
280
+ readonly env: Readonly<Record<string, string>>;
281
+ readonly mergeBase: string;
282
+ readonly ignore: readonly string[];
283
+ }
284
+
285
+ // Each changed path an ignore glob matches goes back to its merge-base state in the index, so the
286
+ // diff leaves it out. The names go to git as the bytes it listed, on stdin, so a name that is not
287
+ // UTF-8 still matches, no count of names outgrows the argument list, and `*` stays a plain letter.
288
+ async function restoreIgnoredPaths(
289
+ host: FingerprintHost,
290
+ request: IgnoredPathsRequest,
291
+ ): Promise<void> {
292
+ if (request.ignore.length === 0) {
293
+ return;
294
+ }
295
+
296
+ const names = await host.runGitBytes({
297
+ cwd: request.root,
298
+ args: [
299
+ 'diff',
300
+ '--cached',
301
+ '--name-only',
302
+ '-z',
303
+ '--no-renames',
304
+ '--no-relative',
305
+ request.mergeBase,
306
+ ],
307
+ env: request.env,
308
+ });
309
+
310
+ const ignored = collectIgnoredNames(Buffer.from(names), request.ignore);
311
+
312
+ if (ignored.length === 0) {
313
+ return;
314
+ }
315
+
316
+ await host.runGit({
317
+ cwd: request.root,
318
+ args: [
319
+ '--literal-pathspecs',
320
+ 'restore',
321
+ '--staged',
322
+ `--source=${request.mergeBase}`,
323
+ '--pathspec-from-file=-',
324
+ '--pathspec-file-nul',
325
+ ],
326
+ stdin: Buffer.concat(ignored.flatMap((name) => [name, Buffer.from([0])])),
327
+ env: request.env,
328
+ });
329
+ }
330
+
331
+ // A name matches as the changed paths decode it, and stays the bytes git wrote. An ignored name
332
+ // stays in the diff when a counted name sits inside it or around it, such as a file that became a
333
+ // folder: restoring one would remove the other from the index.
334
+ function collectIgnoredNames(output: Buffer, ignore: readonly string[]): readonly Buffer[] {
335
+ const names = collectNames(output);
336
+ const ignored = names.filter((name) => isGlobMatch(name.toString('utf8'), ignore));
337
+ const counted = names.filter((name) => !ignored.includes(name));
338
+
339
+ return ignored.filter(
340
+ (name) => !counted.some((other) => isInsidePath(other, name) || isInsidePath(name, other)),
341
+ );
342
+ }
343
+
344
+ function collectNames(output: Buffer): readonly Buffer[] {
345
+ const names: Buffer[] = [];
346
+ let start = 0;
347
+
348
+ while (start < output.length) {
349
+ const end = output.indexOf(0, start);
350
+ const stop = end === -1 ? output.length : end;
351
+
352
+ if (stop > start) {
353
+ names.push(output.subarray(start, stop));
354
+ }
355
+
356
+ start = stop + 1;
357
+ }
358
+
359
+ return names;
360
+ }
361
+
362
+ function isInsidePath(path: Buffer, folder: Buffer): boolean {
363
+ return (
364
+ path.length > folder.length &&
365
+ path[folder.length] === 0x2f &&
366
+ path.subarray(0, folder.length).equals(folder)
367
+ );
368
+ }
@@ -16,7 +16,7 @@ export function renderReportMarkdown(report: Report): string {
16
16
  const lines = [
17
17
  '## wringer report',
18
18
  '',
19
- `Fingerprint \`${report.fingerprint}\` against \`${report.base}\`: ${report.complete ? 'every changed feature is verified' : 'some changed features need a verdict'}.`,
19
+ `Fingerprint \`${report.fingerprint}\` against \`${report.base}\`: ${pickSummary(report)}.`,
20
20
  '',
21
21
  '| Feature | Status | Evidence |',
22
22
  '| --- | --- | --- |',
@@ -25,6 +25,7 @@ export function renderReportMarkdown(report: Report): string {
25
25
  `| ${feature.name} | ${statusLabels[feature.status]} | ${formatEvidence(feature.verdict?.evidence ?? [])} |`,
26
26
  ),
27
27
  '',
28
+ ...formatUnmapped(report.unmapped),
28
29
  reportBlockOpen,
29
30
  JSON.stringify(report).replaceAll('>', String.raw`\u003e`),
30
31
  reportBlockClose,
@@ -34,6 +35,16 @@ export function renderReportMarkdown(report: Report): string {
34
35
  return lines.join('\n');
35
36
  }
36
37
 
38
+ function pickSummary(report: Report): string {
39
+ if (report.complete) {
40
+ return 'every changed feature is verified';
41
+ }
42
+
43
+ return report.unmapped.length > 0
44
+ ? 'some changed paths map to no feature'
45
+ : 'some changed features need a verdict';
46
+ }
47
+
37
48
  function formatEvidence(
38
49
  evidence: readonly {
39
50
  readonly kind: string;
@@ -51,3 +62,16 @@ function formatEvidence(
51
62
  )
52
63
  .join(', ');
53
64
  }
65
+
66
+ function formatUnmapped(unmapped: readonly string[]): readonly string[] {
67
+ if (unmapped.length === 0) {
68
+ return [];
69
+ }
70
+
71
+ return [
72
+ 'Changed paths that no feature maps. Map each to a feature, or add it to the ignore list:',
73
+ '',
74
+ ...unmapped.map((path) => `- \`${path}\``),
75
+ '',
76
+ ];
77
+ }
@@ -32,6 +32,10 @@ export const reportSchema = z.strictObject({
32
32
  fingerprint: z.string(),
33
33
  complete: z.boolean(),
34
34
  features: z.array(featureReportSchema),
35
+
36
+ // The changed paths that no feature maps and the repo does not ignore. A report written before
37
+ // the field existed reads as having none; `gate` works the paths out again either way.
38
+ unmapped: z.array(z.string()).default([]),
35
39
  });
36
40
 
37
41
  export type Report = z.infer<typeof reportSchema>;
package/src/types.ts CHANGED
@@ -427,6 +427,10 @@ export interface Wringer {
427
427
  readonly commands: ReadonlyMap<string, RegisteredCommand>;
428
428
  readonly checks: readonly RunnableRepoCheck[];
429
429
  readonly features: readonly FeatureDefinition[];
430
+
431
+ // Globs of paths with no runtime effect, such as docs: a change to one maps to no feature and
432
+ // leaves the fingerprint alone.
433
+ readonly ignore: readonly string[];
430
434
  readonly hooks: Partial<HookHandlers>;
431
435
  }
432
436