@savvy-web/silk-effects 9.2.1 → 9.3.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.
@@ -95,7 +95,7 @@ const DependencyTableTypeSchema = Schema.Literals([
95
95
  *
96
96
  * @public
97
97
  */
98
- const VERSION_RE = /^(\u2014|[~^]?\d+\.\d+\.\d+(?:[-+.][\w.+-]+)?|(?:catalog|workspace|npm|jsr|file|link|portal):[^\s|]+)$/;
98
+ const VERSION_RE = /^(\u2014|[~^]?\d+\.\d+\.\d+(?:[-+.][\w.+-]+)?|(?:catalog|workspace|npm|jsr|file|link|portal):[^\s|]+)$/u;
99
99
  /**
100
100
  * Version string or em dash (U+2014) sentinel for added/removed entries.
101
101
  *
@@ -40,7 +40,7 @@ import { Schema } from "effect";
40
40
  *
41
41
  * @public
42
42
  */
43
- const CommitHashSchema = Schema.String.check(Schema.isPattern(/^[a-f0-9]{7,}$/, { message: "Commit hash must be 7 or more lowercase hexadecimal characters (0-9, a-f). Example: \"a1b2c3d\" or a full 40-character SHA like \"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\". Uppercase letters are not allowed" }));
43
+ const CommitHashSchema = Schema.String.check(Schema.isPattern(/^[a-f0-9]{7,}$/u, { message: "Commit hash must be 7 or more lowercase hexadecimal characters (0-9, a-f). Example: \"a1b2c3d\" or a full 40-character SHA like \"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\". Uppercase letters are not allowed" }));
44
44
  /**
45
45
  * Semantic version bump type.
46
46
  *
@@ -52,7 +52,7 @@ function isValidUrl(value) {
52
52
  *
53
53
  * @public
54
54
  */
55
- const UsernameSchema = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/, { message: "Invalid GitHub username format. Usernames must contain only alphanumeric characters and hyphens, and cannot start or end with a hyphen. Example: \"octocat\" or \"my-user-123\"" }));
55
+ const UsernameSchema = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/u, { message: "Invalid GitHub username format. Usernames must contain only alphanumeric characters and hyphens, and cannot start or end with a hyphen. Example: \"octocat\" or \"my-user-123\"" }));
56
56
  /**
57
57
  * Schema for a GitHub issue or PR number (positive integer).
58
58
  *
@@ -23,7 +23,7 @@ import { Effect, Schema } from "effect";
23
23
  *
24
24
  */
25
25
  /** Regex for `owner/repo` format, shared between schema and validation. */
26
- const REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
26
+ const REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/u;
27
27
  /**
28
28
  * Schema for a GitHub repository in `owner/repo` format.
29
29
  *
@@ -54,7 +54,7 @@ import { Schema } from "effect";
54
54
  *
55
55
  * @public
56
56
  */
57
- const JsonPathSchema = Schema.String.check(Schema.isPattern(/^\$\.[^.]/, { message: "JSONPath must start with \"$.\" followed by a property (e.g., \"$.version\", \"$.metadata.version\")" }));
57
+ const JsonPathSchema = Schema.String.check(Schema.isPattern(/^\$\.[^.]/u, { message: "JSONPath must start with \"$.\" followed by a property (e.g., \"$.version\", \"$.metadata.version\")" }));
58
58
  /**
59
59
  * Schema for a single version file configuration entry — **new shape**.
60
60
  *
@@ -103,6 +103,7 @@ function resolveBaseBranch(git, opts) {
103
103
  }
104
104
  function makeShape(inspector, git) {
105
105
  const analyzeBranch = (cwd, opts) => Effect.gen(function* () {
106
+ yield* inspector.refreshIn(cwd);
106
107
  const inspected = yield* inspector.inspect(cwd);
107
108
  const baseBranch = yield* resolveBaseBranch(git, {
108
109
  explicit: opts?.baseBranch,
@@ -5,12 +5,12 @@ import { ChangesetConfig } from "../../services/ChangesetConfig.js";
5
5
  import { SilkPublishability } from "../../services/SilkPublishability.js";
6
6
  import { ConfigInspector } from "./config-inspector.js";
7
7
  import { computeWorkspaceDependencyDiffs } from "../utils/dep-diff.js";
8
- import { gitListChangesetFilesAtRef, gitMergeBase, gitShowFileAtRef } from "../utils/git.js";
8
+ import { gitListChangesetFilesAtRef, gitMergeBase, gitResolveRef, gitShowFileAtRef } from "../utils/git.js";
9
9
  import { listPublishablePackageNames } from "../utils/publishability.js";
10
10
  import { Context, Effect, FileSystem, Layer, Option } from "effect";
11
11
  import { basename, join, resolve } from "node:path";
12
12
  import { Git } from "@effected/git";
13
- import { PublishabilityDetector, WorkspaceDiscovery, WorkspaceSnapshots, Workspaces } from "@effected/workspaces";
13
+ import { LockfileReader, PublishabilityDetector, WorkspaceCatalogs, WorkspaceDiscovery, WorkspaceSnapshots, Workspaces } from "@effected/workspaces";
14
14
  import { Yaml } from "@effected/yaml";
15
15
 
16
16
  //#region src/changesets/services/deps-regen.ts
@@ -322,24 +322,35 @@ var DepsRegen = class extends Context.Service()("Changesets/DepsRegen") {
322
322
  /**
323
323
  * Production layer for {@link DepsRegen}.
324
324
  *
325
- * Requires `WorkspaceSnapshots`, `WorkspaceDiscovery`,
326
- * `PublishabilityDetector` (all from `@effected/workspaces`),
325
+ * Requires `WorkspaceSnapshots`, `WorkspaceDiscovery`, `WorkspaceCatalogs`,
326
+ * `LockfileReader`, `PublishabilityDetector` (all from `@effected/workspaces`),
327
327
  * `Git` (from `@effected/git`, backing merge-base resolution),
328
328
  * {@link ConfigInspector}, {@link ChangesetConfig}, and
329
329
  * `FileSystem.FileSystem` (resolved once at construction and closed over by
330
330
  * the shape, keeping `plan`/`execute` themselves requirement-free).
331
331
  *
332
+ * `WorkspaceCatalogs` and `LockfileReader` must be the SAME instances the
333
+ * provided `WorkspaceSnapshots` reads its worktree through (one shared kit
334
+ * graph, as `Workspaces.*` composites give): `plan` refreshes them before
335
+ * snapshotting the worktree so a long-lived host never diffs against a
336
+ * memo from an earlier call (savvy-web/systems#715).
337
+ *
332
338
  * @public
333
339
  */
334
340
  static layer = Layer.effect(this, Effect.gen(function* () {
335
341
  const snapshots = yield* WorkspaceSnapshots;
336
342
  const inspector = yield* ConfigInspector;
337
343
  const discovery = yield* WorkspaceDiscovery;
344
+ const catalogs = yield* WorkspaceCatalogs;
345
+ const lockfiles = yield* LockfileReader;
338
346
  const detector = yield* PublishabilityDetector;
339
347
  const config = yield* ChangesetConfig;
340
348
  const fs = yield* FileSystem.FileSystem;
341
349
  const git = yield* Git;
342
- return makeShape(snapshots, inspector, discovery, detector, config, fs, Layer.succeed(Git, git));
350
+ return makeShape(snapshots, inspector, discovery, {
351
+ catalogs,
352
+ lockfiles
353
+ }, detector, config, fs, Layer.succeed(Git, git));
343
354
  }));
344
355
  };
345
356
  /**
@@ -347,7 +358,7 @@ var DepsRegen = class extends Context.Service()("Changesets/DepsRegen") {
347
358
  * implementations, keeping the public `plan`/`execute` signatures
348
359
  * requirement-free (`R = never`).
349
360
  */
350
- function makeShape(snapshots, inspector, discovery, detector, config, fs, provideGit) {
361
+ function makeShape(snapshots, inspector, discovery, worktreeMemos, detector, config, fs, provideGit) {
351
362
  const provideDetector = Layer.succeed(PublishabilityDetector, detector);
352
363
  const fileExists = (p) => fs.exists(p).pipe(Effect.orElseSucceed(() => false));
353
364
  const plan = (options) => Effect.gen(function* () {
@@ -355,18 +366,21 @@ function makeShape(snapshots, inspector, discovery, detector, config, fs, provid
355
366
  const changesetDir = join(resolvedCwd, ".changeset");
356
367
  yield* inspector.refresh();
357
368
  yield* config.refresh();
358
- let fromRef = options.from;
369
+ yield* worktreeMemos.lockfiles.refresh();
370
+ yield* worktreeMemos.catalogs.refresh();
371
+ let fromRef = options.from ? yield* gitResolveRef(resolvedCwd, options.from).pipe(Effect.provide(provideGit)) : "";
359
372
  if (!fromRef) {
360
373
  let baseBranch = options.base;
361
374
  if (!baseBranch) baseBranch = (yield* inspector.inspect(resolvedCwd).pipe(Effect.catchTag("ConfigurationError", () => Effect.succeed({ baseBranch: "main" })))).baseBranch;
362
375
  fromRef = yield* gitMergeBase(resolvedCwd, baseBranch).pipe(Effect.provide(provideGit));
363
376
  }
377
+ const toRef = options.to ? yield* gitResolveRef(resolvedCwd, options.to).pipe(Effect.provide(provideGit)) : void 0;
364
378
  const before = yield* snapshots.at(fromRef);
365
- const after = options.to ? yield* snapshots.at(options.to) : yield* snapshots.worktree();
379
+ const after = toRef ? yield* snapshots.at(toRef) : yield* snapshots.worktree();
366
380
  const workspaceYaml = "pnpm-workspace.yaml";
367
381
  const declaredBefore = yield* declaredConfigDependencies(yield* gitShowFileAtRef(resolvedCwd, fromRef, workspaceYaml).pipe(Effect.provide(provideGit)));
368
- const declaredAfter = yield* declaredConfigDependencies(options.to ? yield* gitShowFileAtRef(resolvedCwd, options.to, workspaceYaml).pipe(Effect.provide(provideGit)) : yield* fs.readFileString(join(resolvedCwd, workspaceYaml)).pipe(Effect.option));
369
- yield* assertHooksReplayed(fromRef, declaredBefore, before);
382
+ const declaredAfter = yield* declaredConfigDependencies(toRef ? yield* gitShowFileAtRef(resolvedCwd, toRef, workspaceYaml).pipe(Effect.provide(provideGit)) : yield* fs.readFileString(join(resolvedCwd, workspaceYaml)).pipe(Effect.option));
383
+ yield* assertHooksReplayed(options.from || fromRef, declaredBefore, before);
370
384
  yield* assertHooksReplayed(options.to ?? "worktree", declaredAfter, after);
371
385
  const rawDiffs = computeWorkspaceDependencyDiffs(before, after);
372
386
  const explicitTargets = /* @__PURE__ */ new Set([...options.packages ?? [], ...options.package ? [options.package] : []]);
@@ -475,6 +475,7 @@ function applyEffect(root, dryRun, changelogModules, snapshot, inspector, fs) {
475
475
  });
476
476
  }
477
477
  const newVersionByName = new Map(plan.releases.map((r) => [r.name, r.newVersion]));
478
+ yield* inspector.refreshIn(root);
478
479
  const inspected = yield* inspector.inspect(root).pipe(Effect.catch((error) => Effect.logWarning(`Skipping versionFiles update: ${errMsg(error)}`).pipe(Effect.as(null))));
479
480
  let versionFileUpdates = [];
480
481
  if (inspected) {
@@ -32,6 +32,28 @@ function gitMergeBase(cwd, base) {
32
32
  });
33
33
  }
34
34
  /**
35
+ * Pin `ref` to the commit it names right now (`git rev-parse --verify`).
36
+ *
37
+ * @remarks
38
+ * `WorkspaceSnapshots.at(ref)` caches per ref STRING for the layer's
39
+ * lifetime, which is only sound for an immutable ref. A branch or tag name
40
+ * handed to a long-lived host (savvy-mcp) would otherwise keep answering
41
+ * from the commit it named on first use (savvy-web/systems#715); keying the
42
+ * snapshot on the SHA makes every cache hit correct by construction.
43
+ *
44
+ * Tolerant: an unresolvable ref, or a `cwd` outside a git repository, hands
45
+ * back `ref` unchanged, so the snapshot read that follows reports the
46
+ * failure in its own typed terms (and synthetic refs keep working against
47
+ * test doubles).
48
+ *
49
+ * @internal
50
+ */
51
+ function gitResolveRef(cwd, ref) {
52
+ return Effect.gen(function* () {
53
+ return yield* (yield* Git).revParse(cwd, ref).pipe(Effect.orElseSucceed(() => ref));
54
+ });
55
+ }
56
+ /**
35
57
  * List the basenames of `.changeset/*.md` files tracked at `ref` (e.g. the
36
58
  * merge base), via `git ls-tree -r`. Used by `DepsRegen.plan()`
37
59
  * to protect changesets authored by already-merged PRs from being deleted by
@@ -84,4 +106,4 @@ function gitShowFileAtRef(cwd, ref, path) {
84
106
  }
85
107
 
86
108
  //#endregion
87
- export { gitListChangesetFilesAtRef, gitMergeBase, gitShowFileAtRef };
109
+ export { gitListChangesetFilesAtRef, gitMergeBase, gitResolveRef, gitShowFileAtRef };
package/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { AnalyzedWorkspace, BiomeSyncError, BiomeSyncError as BiomeSyncError$1, BiomeSyncOptions, BiomeSyncResult, BiomeSyncResult as BiomeSyncResult$1, ChangesetConfigError, ChangesetConfigError as ChangesetConfigError$1, ChangesetConfigFile, ChangesetConfigFile as ChangesetConfigFile$1, ConfigDiscoveryOptions, ConfigLocation, ConfigLocation as ConfigLocation$1, ConfigNotFoundError, ConfigSource, LIFECYCLE_SCRIPTS_CONFIG_KEY, PrBody, PublishTargetBindingError, PublishTargetBindingError as PublishTargetBindingError$1, SavvyBaseSection, SavvyHooksSection, SavvyInstallHook, SavvyInstallSection, SavvyOkfSection, SavvyToolchainSection, SilkChangesetConfigFile, SilkChangesetConfigFile as SilkChangesetConfigFile$1, SilkPublishConfig, WorkspaceAnalysis, WorkspaceAnalysis as WorkspaceAnalysis$1, WorkspaceAnalysisError, WorkspaceAnalysisError as WorkspaceAnalysisError$1, publishesBuiltLinkDirectory, savvyBasePreamble, savvyHooksHygiene, savvyInstallBlock, savvyInstallDeps, savvyOkfBlock, savvyOkfSync, savvyToolSection, savvyToolchainCheck } from "@savvy-web/silk-core";
2
- import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
2
+ import { Context, Effect, FileSystem, Layer, Path, PlatformError, Schema } from "effect";
3
3
  import { Plugin } from "unified";
4
4
  import { ChildProcessSpawner } from "effect/process";
5
- import { PackageManagerDetector, PublishTarget, PublishabilityDetector, WorkspaceDiscovery, WorkspaceDiscoveryFailure, WorkspacePackage, WorkspaceSnapshotAtFailure, WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, WorkspaceStateSnapshot, WorkspacesOptions } from "@effected/workspaces";
6
- import { Git } from "@effected/git";
5
+ import { LockfileReader, PackageManagerDetector, PublishTarget, PublishabilityDetector, WorkspaceCatalogs, WorkspaceDiscovery, WorkspaceDiscoveryFailure, WorkspacePackage, WorkspaceSnapshotAtFailure, WorkspaceSnapshotWorktreeFailure, WorkspaceSnapshots, WorkspaceStateSnapshot, WorkspacesOptions } from "@effected/workspaces";
6
+ import { Git, GitCommandError, NotARepositoryError, UnknownRefError } from "@effected/git";
7
7
  import { GlobExpansionError } from "@effected/walker";
8
- import * as PlatformError from "effect/PlatformError";
8
+ import * as PlatformError$1 from "effect/PlatformError";
9
9
  import { YamlFormattingOptions } from "@effected/yaml";
10
10
  import { Section, SectionId } from "@effected/templates";
11
11
  import { ToolDiscovery } from "@effected/commands";
@@ -3943,16 +3943,22 @@ declare class DepsRegen extends DepsRegen_base {
3943
3943
  /**
3944
3944
  * Production layer for {@link DepsRegen}.
3945
3945
  *
3946
- * Requires `WorkspaceSnapshots`, `WorkspaceDiscovery`,
3947
- * `PublishabilityDetector` (all from `@effected/workspaces`),
3946
+ * Requires `WorkspaceSnapshots`, `WorkspaceDiscovery`, `WorkspaceCatalogs`,
3947
+ * `LockfileReader`, `PublishabilityDetector` (all from `@effected/workspaces`),
3948
3948
  * `Git` (from `@effected/git`, backing merge-base resolution),
3949
3949
  * {@link ConfigInspector}, {@link ChangesetConfig}, and
3950
3950
  * `FileSystem.FileSystem` (resolved once at construction and closed over by
3951
3951
  * the shape, keeping `plan`/`execute` themselves requirement-free).
3952
3952
  *
3953
+ * `WorkspaceCatalogs` and `LockfileReader` must be the SAME instances the
3954
+ * provided `WorkspaceSnapshots` reads its worktree through (one shared kit
3955
+ * graph, as `Workspaces.*` composites give): `plan` refreshes them before
3956
+ * snapshotting the worktree so a long-lived host never diffs against a
3957
+ * memo from an earlier call (savvy-web/systems#715).
3958
+ *
3953
3959
  * @public
3954
3960
  */
3955
- static readonly layer: Layer.Layer<DepsRegen, never, WorkspaceSnapshots | ConfigInspector | WorkspaceDiscovery | PublishabilityDetector | ChangesetConfig | Git | FileSystem.FileSystem>;
3961
+ static readonly layer: Layer.Layer<DepsRegen, never, WorkspaceSnapshots | ConfigInspector | WorkspaceDiscovery | WorkspaceCatalogs | LockfileReader | PublishabilityDetector | ChangesetConfig | Git | FileSystem.FileSystem>;
3956
3962
  }
3957
3963
  /**
3958
3964
  * Build the batteries-included {@link DepsRegen} layer over a
@@ -5119,7 +5125,7 @@ declare class VersionFiles {
5119
5125
  * @see {@link jsonPathResolve} for concrete-path enumeration
5120
5126
  * @see {@link VersionFiles.applyVersionEdit} for the per-path edit
5121
5127
  */
5122
- static updateFile(filePath: string, jsonPaths: readonly string[], version: string): Effect.Effect<VersionFileUpdate | undefined, PlatformError.PlatformError, FileSystem.FileSystem>;
5128
+ static updateFile(filePath: string, jsonPaths: readonly string[], version: string): Effect.Effect<VersionFileUpdate | undefined, PlatformError$1.PlatformError, FileSystem.FileSystem>;
5123
5129
  /**
5124
5130
  * Compute the full update for a document without touching the filesystem:
5125
5131
  * the edited content, the previous values at every matched path, and how
@@ -6955,6 +6961,23 @@ interface ShellScriptsOptions extends BaseHandlerOptions {
6955
6961
  */
6956
6962
  makeExecutable?: boolean;
6957
6963
  }
6964
+ /**
6965
+ * Options for the TextFiles handler: only the shared `exclude` list.
6966
+ */
6967
+ type TextFilesOptions = BaseHandlerOptions;
6968
+ /**
6969
+ * Options for `TextFiles.listTracked`.
6970
+ */
6971
+ interface TextFilesListOptions extends BaseHandlerOptions {
6972
+ /**
6973
+ * List for a staged (index) check: keep every regular-file index entry,
6974
+ * including one deleted from the working tree without the deletion being
6975
+ * staged — the commit still records its blob. When `false`, entries with
6976
+ * no working-tree file are dropped, since a worktree read would fail.
6977
+ * @defaultValue false
6978
+ */
6979
+ staged?: boolean;
6980
+ }
6958
6981
  /**
6959
6982
  * Options for the Yaml handler.
6960
6983
  */
@@ -7018,6 +7041,10 @@ interface CreateConfigOptions {
7018
7041
  * Options for ShellScripts handler, or false to disable.
7019
7042
  */
7020
7043
  shellScripts?: ShellScriptsOptions | false;
7044
+ /**
7045
+ * Options for TextFiles handler (NUL-byte / invalid UTF-8 check), or false to disable.
7046
+ */
7047
+ textFiles?: TextFilesOptions | false;
7021
7048
  /**
7022
7049
  * Options for Yaml handler, or false to disable.
7023
7050
  */
@@ -7519,6 +7546,244 @@ declare class ShellScripts {
7519
7546
  static create(options?: ShellScriptsOptions): LintStagedHandler;
7520
7547
  }
7521
7548
  //#endregion
7549
+ //#region src/lint/handlers/TextFiles.d.ts
7550
+ /**
7551
+ * Why a file is not grep-visible text.
7552
+ *
7553
+ * @remarks
7554
+ * `"nul"` — the file contains a NUL byte, which ripgrep (anywhere) and
7555
+ * `git grep -I` (first 8000 bytes) take as the mark of a binary file and
7556
+ * silently skip. `"invalid-utf8"` — the bytes are not well-formed UTF-8.
7557
+ *
7558
+ * @public
7559
+ */
7560
+ declare const TextFileViolationReason: Schema.Literals<readonly ["nul", "invalid-utf8"]>;
7561
+ /** @public */
7562
+ type TextFileViolationReason = typeof TextFileViolationReason.Type;
7563
+ declare const TextFileViolation_base: Schema.Class<TextFileViolation, Schema.Struct<{
7564
+ readonly reason: Schema.Literals<readonly ["nul", "invalid-utf8"]>;
7565
+ readonly offset: Schema.Number;
7566
+ readonly line: Schema.Number;
7567
+ readonly column: Schema.Number;
7568
+ }>, {}>;
7569
+ /**
7570
+ * The first occurrence of one {@link TextFileViolationReason} in a byte buffer.
7571
+ *
7572
+ * @remarks
7573
+ * `offset` is the 0-based byte offset of the offending byte (for
7574
+ * `"invalid-utf8"`, the first byte of the ill-formed sequence). `line` is
7575
+ * 1-based and unambiguous even in invalid input: LF never occurs inside a
7576
+ * multi-byte UTF-8 sequence. `column` is the 1-based character column,
7577
+ * counting code points on the line before the offset (a leading BOM is not
7578
+ * counted); when the line prefix itself holds invalid bytes it is an
7579
+ * approximation, and `offset` is the exact position.
7580
+ *
7581
+ * @public
7582
+ */
7583
+ declare class TextFileViolation extends TextFileViolation_base {}
7584
+ declare const TextFileFinding_base: Schema.Class<TextFileFinding, Schema.Struct<{
7585
+ readonly path: Schema.String;
7586
+ readonly reason: Schema.Literals<readonly ["nul", "invalid-utf8"]>;
7587
+ readonly offset: Schema.Number;
7588
+ readonly line: Schema.Number;
7589
+ readonly column: Schema.Number;
7590
+ }>, {}>;
7591
+ /**
7592
+ * A {@link TextFileViolation} found in a named file.
7593
+ *
7594
+ * @public
7595
+ */
7596
+ declare class TextFileFinding extends TextFileFinding_base {
7597
+ /** `path:line:column`, the form editors and terminals turn into a link. */
7598
+ get location(): string;
7599
+ /** A one-line explanation with the mechanical fix. */
7600
+ get message(): string;
7601
+ }
7602
+ /**
7603
+ * Base of {@link TextFileStagedReadError}, exported so downstream declaration
7604
+ * emit can name it (an unexported `Data.TaggedError` base fails TS4023 in any
7605
+ * package whose inferred types carry the error).
7606
+ *
7607
+ * @internal
7608
+ */
7609
+ declare const TextFileStagedReadErrorBase: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
7610
+ readonly _tag: "TextFileStagedReadError";
7611
+ } & Readonly<A>;
7612
+ /**
7613
+ * A file's staged (index) content could not be read.
7614
+ *
7615
+ * @remarks
7616
+ * Raised by {@link TextFiles.readStaged} when `git cat-file` exits non-zero —
7617
+ * typically because the path has no stage-0 index entry (never added, or
7618
+ * mid-merge), or the directory is not inside a git repository.
7619
+ *
7620
+ * @public
7621
+ */
7622
+ declare class TextFileStagedReadError extends TextFileStagedReadErrorBase<{
7623
+ readonly path: string;
7624
+ readonly exitCode: number;
7625
+ readonly stderr: string;
7626
+ }> {
7627
+ get message(): string;
7628
+ }
7629
+ /**
7630
+ * The outcome of {@link TextFiles.checkStagedFiles}: the findings in every
7631
+ * staged blob that could be read, and a read error for each file that could
7632
+ * not.
7633
+ *
7634
+ * @remarks
7635
+ * An unreadable file does not stop the check: every other file is still read
7636
+ * and classified, so one path without an index entry cannot hide a finding
7637
+ * elsewhere. Both arrays keep the input order.
7638
+ *
7639
+ * @public
7640
+ */
7641
+ interface TextFileStagedCheck {
7642
+ /** One finding per violation across the readable files. */
7643
+ readonly findings: ReadonlyArray<TextFileFinding>;
7644
+ /** One error per file whose staged content could not be read. */
7645
+ readonly unreadable: ReadonlyArray<TextFileStagedReadError>;
7646
+ }
7647
+ /**
7648
+ * Handler for grep-visible text files.
7649
+ *
7650
+ * Fails a commit when a staged source file contains a NUL byte or is not
7651
+ * valid UTF-8 — the two ways a text file becomes invisible to `grep`/`rg`,
7652
+ * which then return false negatives instead of an error.
7653
+ *
7654
+ * @remarks
7655
+ * The check is read-only, so its glob may overlap the Biome `--write` key
7656
+ * without a staging conflict. The lint-staged handler shells out to
7657
+ * `savvy lint text --staged <files>` (reading the index, not the worktree),
7658
+ * the same command `savvy lint text` runs with no
7659
+ * arguments over every git-tracked file matching {@link TextFiles.glob}. The
7660
+ * fix is always mechanical: write `\0` as an escape, never a literal NUL.
7661
+ *
7662
+ * @example
7663
+ * ```typescript
7664
+ * import { TextFiles } from '\@savvy-web/silk/lint';
7665
+ *
7666
+ * export default {
7667
+ * [TextFiles.glob]: TextFiles.create({ exclude: ['vendor/'] }),
7668
+ * };
7669
+ * ```
7670
+ *
7671
+ * @public
7672
+ */
7673
+ declare class TextFiles {
7674
+ /**
7675
+ * File extensions checked: source, config, docs and shell text.
7676
+ */
7677
+ static readonly extensions: readonly ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "json", "jsonc", "md", "mdx", "yml", "yaml", "toml", "css", "sh", "bats"];
7678
+ /**
7679
+ * Glob pattern for matching checked files, derived from {@link TextFiles.extensions}.
7680
+ * @defaultValue `'**\/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,jsonc,md,mdx,yml,yaml,toml,css,sh,bats}'`
7681
+ */
7682
+ static readonly glob: string;
7683
+ /**
7684
+ * Default patterns to exclude from checking.
7685
+ * @defaultValue `[]`
7686
+ */
7687
+ static readonly defaultExcludes: readonly [];
7688
+ /**
7689
+ * Pre-configured handler with default options.
7690
+ */
7691
+ static readonly handler: LintStagedHandler;
7692
+ /**
7693
+ * Whether `path` has one of the checked {@link TextFiles.extensions}.
7694
+ *
7695
+ * @param path - A file path
7696
+ * @returns `true` when the file is in scope
7697
+ */
7698
+ static matches(path: string): boolean;
7699
+ /**
7700
+ * Classify a file's bytes.
7701
+ *
7702
+ * @remarks
7703
+ * Pure. Returns the first NUL byte and the first ill-formed UTF-8 sequence,
7704
+ * each at most once, ordered by offset; an empty array means the bytes are
7705
+ * grep-visible text. A UTF-8 BOM is valid, and an empty buffer passes.
7706
+ *
7707
+ * @param bytes - The file contents
7708
+ * @returns The violations found, empty when none
7709
+ */
7710
+ static classify(bytes: Uint8Array): ReadonlyArray<TextFileViolation>;
7711
+ /**
7712
+ * Read and classify each file, in order.
7713
+ *
7714
+ * @param paths - Files to check
7715
+ * @returns One finding per violation, empty when every file passes
7716
+ */
7717
+ static checkFiles(paths: ReadonlyArray<string>): Effect.Effect<ReadonlyArray<TextFileFinding>, PlatformError.PlatformError, FileSystem.FileSystem>;
7718
+ /**
7719
+ * Read a file's staged bytes from the git index, not the working tree.
7720
+ *
7721
+ * @remarks
7722
+ * Runs `git cat-file blob :./<name>` from the file's own directory, so an
7723
+ * absolute path (what lint-staged passes) and a relative one both resolve
7724
+ * against the right repository without computing a repo-relative path.
7725
+ * When that directory is gone from the working tree (a staged file whose
7726
+ * directory was deleted, deletion unstaged), it runs from the nearest
7727
+ * existing ancestor with the path relative to it instead.
7728
+ * Stdout is collected as raw bytes — `\@effected/git`'s `show` decodes to a
7729
+ * string, which would erase exactly the NULs and invalid bytes this check
7730
+ * looks for. Reading the index makes the lint-staged run race-free against
7731
+ * concurrent `--write` tasks on the working tree, and checks exactly what
7732
+ * the commit will record.
7733
+ *
7734
+ * @param path - File path, absolute or relative to the process cwd
7735
+ * @returns The staged blob's bytes
7736
+ */
7737
+ static readStaged(path: string): Effect.Effect<Uint8Array, TextFileStagedReadError | PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path>;
7738
+ /**
7739
+ * Read each file's STAGED content (see {@link TextFiles.readStaged}) and
7740
+ * classify it, in order.
7741
+ *
7742
+ * @remarks
7743
+ * Every file is checked: a file whose staged content cannot be read is
7744
+ * collected into {@link TextFileStagedCheck.unreadable} rather than
7745
+ * failing the whole check, so it cannot hide findings in the others. Only
7746
+ * a platform failure (git could not be spawned) fails the effect.
7747
+ *
7748
+ * @param paths - Files to check
7749
+ * @returns The findings, and a read error per unreadable file; both empty when every staged blob passes
7750
+ */
7751
+ static checkStagedFiles(paths: ReadonlyArray<string>): Effect.Effect<TextFileStagedCheck, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path>;
7752
+ /**
7753
+ * List the git-tracked files under `cwd` that this check covers.
7754
+ *
7755
+ * @remarks
7756
+ * Reads the index (`git ls-files`), keeping regular files (modes `100644`
7757
+ * and `100755`) — gitlinks and symlinks are not file contents — that match
7758
+ * {@link TextFiles.matches} and are not excluded. For a worktree check the
7759
+ * file must also still exist in the working tree; with `staged: true` it
7760
+ * need not, since a file deleted from the worktree without the deletion
7761
+ * staged is still in the commit and {@link TextFiles.readStaged} reads its
7762
+ * blob from the index. Paths come back joined onto `cwd`, deduplicated
7763
+ * across the stages of an unresolved merge.
7764
+ *
7765
+ * @param cwd - Repository directory to list from
7766
+ * @param options - `exclude` patterns (defaults to {@link TextFiles.defaultExcludes}) and `staged`
7767
+ * @returns The files to check
7768
+ */
7769
+ static listTracked(cwd: string, options?: TextFilesListOptions): Effect.Effect<ReadonlyArray<string>, GitCommandError | NotARepositoryError | UnknownRefError | PlatformError.PlatformError, Git | FileSystem.FileSystem | Path.Path>;
7770
+ /**
7771
+ * Create a handler with custom options.
7772
+ *
7773
+ * @remarks
7774
+ * Returns a `savvy lint text --staged <files>` command rather than checking
7775
+ * in the handler body, so the check runs with the same code as the CLI.
7776
+ * `--staged` reads each file from the git index: lint-staged runs other
7777
+ * glob keys (Biome `--write`) concurrently against the working tree, and a
7778
+ * worktree read could observe a half-written file. The index only changes
7779
+ * when lint-staged re-stages, after every task has finished.
7780
+ *
7781
+ * @param options - Configuration options
7782
+ * @returns A lint-staged compatible handler function
7783
+ */
7784
+ static create(options?: TextFilesOptions): LintStagedHandler;
7785
+ }
7786
+ //#endregion
7522
7787
  //#region src/lint/handlers/TypeScript.d.ts
7523
7788
  /**
7524
7789
  * TypeScript compiler to use.
@@ -7826,6 +8091,7 @@ declare class Preset {
7826
8091
  * - Yaml (format + lint)
7827
8092
  * - PnpmWorkspace (sort + format)
7828
8093
  * - ShellScripts (chmod management)
8094
+ * - TextFiles (NUL-byte / invalid UTF-8 check)
7829
8095
  *
7830
8096
  * @param extend - Options to customize or extend the preset
7831
8097
  * @returns A lint-staged configuration object
@@ -7850,6 +8116,7 @@ declare class Preset {
7850
8116
  * - Yaml (format + lint)
7851
8117
  * - PnpmWorkspace (sort + format)
7852
8118
  * - ShellScripts (chmod management)
8119
+ * - TextFiles (NUL-byte / invalid UTF-8 check)
7853
8120
  * - TypeScript (TSDoc + typecheck)
7854
8121
  *
7855
8122
  * @param extend - Options to customize or extend the preset
@@ -8421,7 +8688,7 @@ declare const MARKDOWNLINT_CONFIG: {
8421
8688
  readonly "changeset-dependency-table-format": false;
8422
8689
  };
8423
8690
  declare namespace index_d_exports$2 {
8424
- export { BaseHandlerOptions, Biome, BiomeOptions, Command, CreateConfigOptions, DEFAULT_CONFIG_PATH, Filter, HUSKY_HOOK_PATH, Handler, LegacySavvyLintHygieneDef, LintStagedConfig, LintStagedEntry, LintStagedHandler, MARKDOWNLINT_CONFIG, MARKDOWNLINT_CONFIG_PATH, MARKDOWNLINT_SCHEMA, MARKDOWNLINT_TEMPLATE, Markdown, MarkdownOptions, POST_CHECKOUT_HOOK_PATH, POST_COMMIT_HOOK_PATH, POST_MERGE_HOOK_PATH, PackageJson, PackageJsonOptions, PackageManager, PnpmWorkspace, PnpmWorkspaceContent, PnpmWorkspaceOptions, Preset, PresetExtendOptions, PresetType, SavvyLintSectionDef, ShellScripts, ShellScriptsOptions, ToolSearchResult, TypeScript, TypeScriptCompiler, TypeScriptOptions, WorkspacePackageInfo, Yaml, YamlOptions, createConfig, generateManagedContent, getWorkspacePackagePaths, getWorkspacePackages, getWorkspaceRoot, isWorkspacePackagePath, resetWorkspaceCache, savvyLintBlock };
8691
+ export { BaseHandlerOptions, Biome, BiomeOptions, Command, CreateConfigOptions, DEFAULT_CONFIG_PATH, Filter, HUSKY_HOOK_PATH, Handler, LegacySavvyLintHygieneDef, LintStagedConfig, LintStagedEntry, LintStagedHandler, MARKDOWNLINT_CONFIG, MARKDOWNLINT_CONFIG_PATH, MARKDOWNLINT_SCHEMA, MARKDOWNLINT_TEMPLATE, Markdown, MarkdownOptions, POST_CHECKOUT_HOOK_PATH, POST_COMMIT_HOOK_PATH, POST_MERGE_HOOK_PATH, PackageJson, PackageJsonOptions, PackageManager, PnpmWorkspace, PnpmWorkspaceContent, PnpmWorkspaceOptions, Preset, PresetExtendOptions, PresetType, SavvyLintSectionDef, ShellScripts, ShellScriptsOptions, TextFileFinding, TextFileStagedCheck, TextFileStagedReadError, TextFileStagedReadErrorBase, TextFileViolation, TextFileViolationReason, TextFiles, TextFilesListOptions, TextFilesOptions, ToolSearchResult, TypeScript, TypeScriptCompiler, TypeScriptOptions, WorkspacePackageInfo, Yaml, YamlOptions, createConfig, generateManagedContent, getWorkspacePackagePaths, getWorkspacePackages, getWorkspaceRoot, isWorkspacePackagePath, resetWorkspaceCache, savvyLintBlock };
8425
8692
  }
8426
8693
  //#endregion
8427
8694
  //#region src/repos/constants.d.ts
@@ -46,6 +46,7 @@ var Preset = class Preset {
46
46
  yaml: extend.yaml ?? false,
47
47
  pnpmWorkspace: extend.pnpmWorkspace ?? false,
48
48
  shellScripts: extend.shellScripts ?? false,
49
+ textFiles: extend.textFiles ?? false,
49
50
  typescript: extend.typescript ?? false
50
51
  };
51
52
  if (extend.custom !== void 0) options.custom = extend.custom;
@@ -61,6 +62,7 @@ var Preset = class Preset {
61
62
  * - Yaml (format + lint)
62
63
  * - PnpmWorkspace (sort + format)
63
64
  * - ShellScripts (chmod management)
65
+ * - TextFiles (NUL-byte / invalid UTF-8 check)
64
66
  *
65
67
  * @param extend - Options to customize or extend the preset
66
68
  * @returns A lint-staged configuration object
@@ -82,6 +84,7 @@ var Preset = class Preset {
82
84
  yaml: extend.yaml ?? {},
83
85
  pnpmWorkspace: extend.pnpmWorkspace ?? {},
84
86
  shellScripts: extend.shellScripts ?? {},
87
+ textFiles: extend.textFiles ?? {},
85
88
  typescript: extend.typescript ?? false
86
89
  };
87
90
  if (extend.custom !== void 0) options.custom = extend.custom;
@@ -97,6 +100,7 @@ var Preset = class Preset {
97
100
  * - Yaml (format + lint)
98
101
  * - PnpmWorkspace (sort + format)
99
102
  * - ShellScripts (chmod management)
103
+ * - TextFiles (NUL-byte / invalid UTF-8 check)
100
104
  * - TypeScript (TSDoc + typecheck)
101
105
  *
102
106
  * @param extend - Options to customize or extend the preset
@@ -119,6 +123,7 @@ var Preset = class Preset {
119
123
  yaml: extend.yaml ?? {},
120
124
  pnpmWorkspace: extend.pnpmWorkspace ?? {},
121
125
  shellScripts: extend.shellScripts ?? {},
126
+ textFiles: extend.textFiles ?? {},
122
127
  typescript: extend.typescript ?? {}
123
128
  };
124
129
  if (extend.custom !== void 0) options.custom = extend.custom;
@@ -3,6 +3,7 @@ import { Markdown } from "../handlers/Markdown.js";
3
3
  import { PackageJson } from "../handlers/PackageJson.js";
4
4
  import { PnpmWorkspace } from "../handlers/PnpmWorkspace.js";
5
5
  import { ShellScripts } from "../handlers/ShellScripts.js";
6
+ import { TextFiles } from "../handlers/TextFiles.js";
6
7
  import { TypeScript } from "../handlers/TypeScript.js";
7
8
  import { Yaml } from "../handlers/Yaml.js";
8
9
 
@@ -77,6 +78,10 @@ function createConfig(options = {}) {
77
78
  const handlerOptions = typeof options.shellScripts === "object" ? options.shellScripts : {};
78
79
  config[ShellScripts.glob] = ShellScripts.create(handlerOptions);
79
80
  }
81
+ if (options.textFiles !== false) {
82
+ const handlerOptions = typeof options.textFiles === "object" ? options.textFiles : {};
83
+ config[TextFiles.glob] = TextFiles.create(handlerOptions);
84
+ }
80
85
  if (options.typescript !== false) {
81
86
  const handlerOptions = typeof options.typescript === "object" ? options.typescript : {};
82
87
  config[TypeScript.glob] = TypeScript.create(handlerOptions);
@@ -0,0 +1,391 @@
1
+ import { Command } from "../utils/Command.js";
2
+ import { Filter } from "../utils/Filter.js";
3
+ import { Data, Effect, FileSystem, Path, Schema, Stream } from "effect";
4
+ import { Git } from "@effected/git";
5
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
6
+
7
+ //#region src/lint/handlers/TextFiles.ts
8
+ /**
9
+ * Why a file is not grep-visible text.
10
+ *
11
+ * @remarks
12
+ * `"nul"` — the file contains a NUL byte, which ripgrep (anywhere) and
13
+ * `git grep -I` (first 8000 bytes) take as the mark of a binary file and
14
+ * silently skip. `"invalid-utf8"` — the bytes are not well-formed UTF-8.
15
+ *
16
+ * @public
17
+ */
18
+ const TextFileViolationReason = Schema.Literals(["nul", "invalid-utf8"]);
19
+ /**
20
+ * The first occurrence of one {@link TextFileViolationReason} in a byte buffer.
21
+ *
22
+ * @remarks
23
+ * `offset` is the 0-based byte offset of the offending byte (for
24
+ * `"invalid-utf8"`, the first byte of the ill-formed sequence). `line` is
25
+ * 1-based and unambiguous even in invalid input: LF never occurs inside a
26
+ * multi-byte UTF-8 sequence. `column` is the 1-based character column,
27
+ * counting code points on the line before the offset (a leading BOM is not
28
+ * counted); when the line prefix itself holds invalid bytes it is an
29
+ * approximation, and `offset` is the exact position.
30
+ *
31
+ * @public
32
+ */
33
+ var TextFileViolation = class extends Schema.Class("TextFileViolation")({
34
+ reason: TextFileViolationReason,
35
+ offset: Schema.Number,
36
+ line: Schema.Number,
37
+ column: Schema.Number
38
+ }) {};
39
+ /**
40
+ * A {@link TextFileViolation} found in a named file.
41
+ *
42
+ * @public
43
+ */
44
+ var TextFileFinding = class extends Schema.Class("TextFileFinding")({
45
+ path: Schema.String,
46
+ reason: TextFileViolationReason,
47
+ offset: Schema.Number,
48
+ line: Schema.Number,
49
+ column: Schema.Number
50
+ }) {
51
+ /** `path:line:column`, the form editors and terminals turn into a link. */
52
+ get location() {
53
+ return `${this.path}:${this.line}:${this.column}`;
54
+ }
55
+ /** A one-line explanation with the mechanical fix. */
56
+ get message() {
57
+ return this.reason === "nul" ? "contains a NUL byte — write it as the \\0 escape so grep and ripgrep do not skip the file" : `is not valid UTF-8 (byte offset ${this.offset}) — re-encode the file as UTF-8`;
58
+ }
59
+ };
60
+ /**
61
+ * Base of {@link TextFileStagedReadError}, exported so downstream declaration
62
+ * emit can name it (an unexported `Data.TaggedError` base fails TS4023 in any
63
+ * package whose inferred types carry the error).
64
+ *
65
+ * @internal
66
+ */
67
+ const TextFileStagedReadErrorBase = Data.TaggedError("TextFileStagedReadError");
68
+ /**
69
+ * A file's staged (index) content could not be read.
70
+ *
71
+ * @remarks
72
+ * Raised by {@link TextFiles.readStaged} when `git cat-file` exits non-zero —
73
+ * typically because the path has no stage-0 index entry (never added, or
74
+ * mid-merge), or the directory is not inside a git repository.
75
+ *
76
+ * @public
77
+ */
78
+ var TextFileStagedReadError = class extends TextFileStagedReadErrorBase {
79
+ get message() {
80
+ const detail = this.stderr.trim();
81
+ return `cannot read the staged content of ${this.path} (git cat-file exited ${this.exitCode})${detail ? `: ${detail}` : ""}`;
82
+ }
83
+ };
84
+ /**
85
+ * The 0-based offset of the first byte of the first ill-formed UTF-8
86
+ * sequence, or `-1` when the buffer is well-formed.
87
+ *
88
+ * Follows the Unicode well-formed byte-sequence table (Unicode Standard,
89
+ * Table 3-7), which is exactly what a fatal `TextDecoder("utf-8")` accepts:
90
+ * overlong forms, encoded surrogates, code points past U+10FFFF, stray
91
+ * continuation bytes and truncated sequences are all ill-formed.
92
+ */
93
+ const firstInvalidUtf8 = (bytes) => {
94
+ const n = bytes.length;
95
+ let i = 0;
96
+ while (i < n) {
97
+ const lead = bytes[i];
98
+ if (lead < 128) {
99
+ i++;
100
+ continue;
101
+ }
102
+ let length;
103
+ let lo = 128;
104
+ let hi = 191;
105
+ if (lead >= 194 && lead <= 223) length = 2;
106
+ else if (lead === 224) {
107
+ length = 3;
108
+ lo = 160;
109
+ } else if (lead === 237) {
110
+ length = 3;
111
+ hi = 159;
112
+ } else if (lead >= 225 && lead <= 239) length = 3;
113
+ else if (lead === 240) {
114
+ length = 4;
115
+ lo = 144;
116
+ } else if (lead === 244) {
117
+ length = 4;
118
+ hi = 143;
119
+ } else if (lead >= 241 && lead <= 243) length = 4;
120
+ else return i;
121
+ if (i + length > n) return i;
122
+ const second = bytes[i + 1];
123
+ if (second < lo || second > hi) return i;
124
+ for (let k = 2; k < length; k++) {
125
+ const next = bytes[i + k];
126
+ if (next < 128 || next > 191) return i;
127
+ }
128
+ i += length;
129
+ }
130
+ return -1;
131
+ };
132
+ const hasBom = (bytes) => bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191;
133
+ /** Build the violation for `offset`, resolving its line and character column. */
134
+ const locate = (bytes, reason, offset) => {
135
+ let line = 1;
136
+ let lineStart = 0;
137
+ for (let i = 0; i < offset; i++) if (bytes[i] === 10) {
138
+ line++;
139
+ lineStart = i + 1;
140
+ }
141
+ if (lineStart === 0 && hasBom(bytes) && offset >= 3) lineStart = 3;
142
+ let column = 1;
143
+ for (let i = lineStart; i < offset; i++) if ((bytes[i] & 192) !== 128) column++;
144
+ return TextFileViolation.make({
145
+ reason,
146
+ offset,
147
+ line,
148
+ column
149
+ });
150
+ };
151
+ /**
152
+ * Handler for grep-visible text files.
153
+ *
154
+ * Fails a commit when a staged source file contains a NUL byte or is not
155
+ * valid UTF-8 — the two ways a text file becomes invisible to `grep`/`rg`,
156
+ * which then return false negatives instead of an error.
157
+ *
158
+ * @remarks
159
+ * The check is read-only, so its glob may overlap the Biome `--write` key
160
+ * without a staging conflict. The lint-staged handler shells out to
161
+ * `savvy lint text --staged <files>` (reading the index, not the worktree),
162
+ * the same command `savvy lint text` runs with no
163
+ * arguments over every git-tracked file matching {@link TextFiles.glob}. The
164
+ * fix is always mechanical: write `\0` as an escape, never a literal NUL.
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * import { TextFiles } from '\@savvy-web/silk/lint';
169
+ *
170
+ * export default {
171
+ * [TextFiles.glob]: TextFiles.create({ exclude: ['vendor/'] }),
172
+ * };
173
+ * ```
174
+ *
175
+ * @public
176
+ */
177
+ var TextFiles = class TextFiles {
178
+ /**
179
+ * File extensions checked: source, config, docs and shell text.
180
+ */
181
+ static extensions = [
182
+ "ts",
183
+ "tsx",
184
+ "mts",
185
+ "cts",
186
+ "js",
187
+ "jsx",
188
+ "mjs",
189
+ "cjs",
190
+ "json",
191
+ "jsonc",
192
+ "md",
193
+ "mdx",
194
+ "yml",
195
+ "yaml",
196
+ "toml",
197
+ "css",
198
+ "sh",
199
+ "bats"
200
+ ];
201
+ /**
202
+ * Glob pattern for matching checked files, derived from {@link TextFiles.extensions}.
203
+ * @defaultValue `'**\/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,jsonc,md,mdx,yml,yaml,toml,css,sh,bats}'`
204
+ */
205
+ static glob = `**/*.{${TextFiles.extensions.join(",")}}`;
206
+ /**
207
+ * Default patterns to exclude from checking.
208
+ * @defaultValue `[]`
209
+ */
210
+ static defaultExcludes = [];
211
+ /**
212
+ * Pre-configured handler with default options.
213
+ */
214
+ static handler = TextFiles.create();
215
+ /**
216
+ * Whether `path` has one of the checked {@link TextFiles.extensions}.
217
+ *
218
+ * @param path - A file path
219
+ * @returns `true` when the file is in scope
220
+ */
221
+ static matches(path) {
222
+ return TextFiles.extensions.some((ext) => path.endsWith(`.${ext}`));
223
+ }
224
+ /**
225
+ * Classify a file's bytes.
226
+ *
227
+ * @remarks
228
+ * Pure. Returns the first NUL byte and the first ill-formed UTF-8 sequence,
229
+ * each at most once, ordered by offset; an empty array means the bytes are
230
+ * grep-visible text. A UTF-8 BOM is valid, and an empty buffer passes.
231
+ *
232
+ * @param bytes - The file contents
233
+ * @returns The violations found, empty when none
234
+ */
235
+ static classify(bytes) {
236
+ const violations = [];
237
+ const invalid = firstInvalidUtf8(bytes);
238
+ if (invalid >= 0) violations.push(locate(bytes, "invalid-utf8", invalid));
239
+ const nul = bytes.indexOf(0);
240
+ if (nul >= 0) violations.push(locate(bytes, "nul", nul));
241
+ return violations.sort((a, b) => a.offset - b.offset);
242
+ }
243
+ /**
244
+ * Read and classify each file, in order.
245
+ *
246
+ * @param paths - Files to check
247
+ * @returns One finding per violation, empty when every file passes
248
+ */
249
+ static checkFiles(paths) {
250
+ return Effect.gen(function* () {
251
+ const fs = yield* FileSystem.FileSystem;
252
+ return (yield* Effect.forEach(paths, (path) => Effect.map(fs.readFile(path), (bytes) => TextFiles.classify(bytes).map((v) => TextFileFinding.make({
253
+ path,
254
+ ...v
255
+ }))), { concurrency: 16 })).flat();
256
+ });
257
+ }
258
+ /**
259
+ * Read a file's staged bytes from the git index, not the working tree.
260
+ *
261
+ * @remarks
262
+ * Runs `git cat-file blob :./<name>` from the file's own directory, so an
263
+ * absolute path (what lint-staged passes) and a relative one both resolve
264
+ * against the right repository without computing a repo-relative path.
265
+ * When that directory is gone from the working tree (a staged file whose
266
+ * directory was deleted, deletion unstaged), it runs from the nearest
267
+ * existing ancestor with the path relative to it instead.
268
+ * Stdout is collected as raw bytes — `\@effected/git`'s `show` decodes to a
269
+ * string, which would erase exactly the NULs and invalid bytes this check
270
+ * looks for. Reading the index makes the lint-staged run race-free against
271
+ * concurrent `--write` tasks on the working tree, and checks exactly what
272
+ * the commit will record.
273
+ *
274
+ * @param path - File path, absolute or relative to the process cwd
275
+ * @returns The staged blob's bytes
276
+ */
277
+ static readStaged(path) {
278
+ return Effect.scoped(Effect.gen(function* () {
279
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
280
+ const fs = yield* FileSystem.FileSystem;
281
+ const pathService = yield* Path.Path;
282
+ let cwd = pathService.dirname(path);
283
+ while (!(yield* fs.exists(cwd))) {
284
+ const parent = pathService.dirname(cwd);
285
+ if (parent === cwd) break;
286
+ cwd = parent;
287
+ }
288
+ const relative = pathService.relative(cwd, path).split(pathService.sep).join("/");
289
+ const command = ChildProcess.make("git", [
290
+ "cat-file",
291
+ "blob",
292
+ `:./${relative}`
293
+ ], { cwd });
294
+ const handle = yield* spawner.spawn(command);
295
+ const [chunks, stderr, exitCode] = yield* Effect.all([
296
+ Stream.runCollect(handle.stdout),
297
+ Stream.mkString(Stream.decodeText(handle.stderr)),
298
+ handle.exitCode
299
+ ], { concurrency: "unbounded" });
300
+ if (exitCode !== 0) return yield* new TextFileStagedReadError({
301
+ path,
302
+ exitCode,
303
+ stderr
304
+ });
305
+ const out = new Uint8Array(chunks.reduce((n, chunk) => n + chunk.length, 0));
306
+ let at = 0;
307
+ for (const chunk of chunks) {
308
+ out.set(chunk, at);
309
+ at += chunk.length;
310
+ }
311
+ return out;
312
+ }));
313
+ }
314
+ /**
315
+ * Read each file's STAGED content (see {@link TextFiles.readStaged}) and
316
+ * classify it, in order.
317
+ *
318
+ * @remarks
319
+ * Every file is checked: a file whose staged content cannot be read is
320
+ * collected into {@link TextFileStagedCheck.unreadable} rather than
321
+ * failing the whole check, so it cannot hide findings in the others. Only
322
+ * a platform failure (git could not be spawned) fails the effect.
323
+ *
324
+ * @param paths - Files to check
325
+ * @returns The findings, and a read error per unreadable file; both empty when every staged blob passes
326
+ */
327
+ static checkStagedFiles(paths) {
328
+ return Effect.map(Effect.forEach(paths, (path) => TextFiles.readStaged(path).pipe(Effect.map((bytes) => TextFiles.classify(bytes).map((v) => TextFileFinding.make({
329
+ path,
330
+ ...v
331
+ }))), Effect.catchTag("TextFileStagedReadError", (error) => Effect.succeed(error))), { concurrency: 8 }), (perFile) => ({
332
+ findings: perFile.flatMap((outcome) => Array.isArray(outcome) ? outcome : []),
333
+ unreadable: perFile.filter((outcome) => !Array.isArray(outcome))
334
+ }));
335
+ }
336
+ /**
337
+ * List the git-tracked files under `cwd` that this check covers.
338
+ *
339
+ * @remarks
340
+ * Reads the index (`git ls-files`), keeping regular files (modes `100644`
341
+ * and `100755`) — gitlinks and symlinks are not file contents — that match
342
+ * {@link TextFiles.matches} and are not excluded. For a worktree check the
343
+ * file must also still exist in the working tree; with `staged: true` it
344
+ * need not, since a file deleted from the worktree without the deletion
345
+ * staged is still in the commit and {@link TextFiles.readStaged} reads its
346
+ * blob from the index. Paths come back joined onto `cwd`, deduplicated
347
+ * across the stages of an unresolved merge.
348
+ *
349
+ * @param cwd - Repository directory to list from
350
+ * @param options - `exclude` patterns (defaults to {@link TextFiles.defaultExcludes}) and `staged`
351
+ * @returns The files to check
352
+ */
353
+ static listTracked(cwd, options = {}) {
354
+ return Effect.gen(function* () {
355
+ const git = yield* Git;
356
+ const fs = yield* FileSystem.FileSystem;
357
+ const path = yield* Path.Path;
358
+ const excludes = options.exclude ?? [...TextFiles.defaultExcludes];
359
+ const entries = yield* git.lsFiles(cwd);
360
+ const candidates = [...new Set(entries.filter((entry) => entry.mode === "100644" || entry.mode === "100755").map((entry) => entry.path).filter((file) => TextFiles.matches(file)))];
361
+ const kept = Filter.exclude(candidates, excludes).map((file) => path.join(cwd, file));
362
+ if (options.staged) return kept;
363
+ return yield* Effect.filter(kept, (file) => fs.exists(file), { concurrency: 16 });
364
+ });
365
+ }
366
+ /**
367
+ * Create a handler with custom options.
368
+ *
369
+ * @remarks
370
+ * Returns a `savvy lint text --staged <files>` command rather than checking
371
+ * in the handler body, so the check runs with the same code as the CLI.
372
+ * `--staged` reads each file from the git index: lint-staged runs other
373
+ * glob keys (Biome `--write`) concurrently against the working tree, and a
374
+ * worktree read could observe a half-written file. The index only changes
375
+ * when lint-staged re-stages, after every task has finished.
376
+ *
377
+ * @param options - Configuration options
378
+ * @returns A lint-staged compatible handler function
379
+ */
380
+ static create(options = {}) {
381
+ const excludes = options.exclude ?? [...TextFiles.defaultExcludes];
382
+ return (filenames) => {
383
+ const filtered = Filter.exclude(filenames, excludes);
384
+ if (filtered.length === 0) return [];
385
+ return `${Command.findSavvyLint()} text --staged ${Filter.shellEscape(filtered)}`;
386
+ };
387
+ }
388
+ };
389
+
390
+ //#endregion
391
+ export { TextFileFinding, TextFileStagedReadError, TextFileStagedReadErrorBase, TextFileViolation, TextFileViolationReason, TextFiles };
package/lint/index.js CHANGED
@@ -9,6 +9,7 @@ import { Markdown } from "./handlers/Markdown.js";
9
9
  import { PackageJson } from "./handlers/PackageJson.js";
10
10
  import { PnpmWorkspace } from "./handlers/PnpmWorkspace.js";
11
11
  import { ShellScripts } from "./handlers/ShellScripts.js";
12
+ import { TextFileFinding, TextFileStagedReadError, TextFileStagedReadErrorBase, TextFileViolation, TextFileViolationReason, TextFiles } from "./handlers/TextFiles.js";
12
13
  import { TypeScript } from "./handlers/TypeScript.js";
13
14
  import { Yaml } from "./handlers/Yaml.js";
14
15
  import { createConfig } from "./config/createConfig.js";
@@ -37,6 +38,12 @@ var lint_exports = /* @__PURE__ */ __exportAll({
37
38
  Preset: () => Preset,
38
39
  SavvyLintSectionDef: () => SavvyLintSectionDef,
39
40
  ShellScripts: () => ShellScripts,
41
+ TextFileFinding: () => TextFileFinding,
42
+ TextFileStagedReadError: () => TextFileStagedReadError,
43
+ TextFileStagedReadErrorBase: () => TextFileStagedReadErrorBase,
44
+ TextFileViolation: () => TextFileViolation,
45
+ TextFileViolationReason: () => TextFileViolationReason,
46
+ TextFiles: () => TextFiles,
40
47
  TypeScript: () => TypeScript,
41
48
  Yaml: () => Yaml,
42
49
  createConfig: () => createConfig,
@@ -50,4 +57,4 @@ var lint_exports = /* @__PURE__ */ __exportAll({
50
57
  });
51
58
 
52
59
  //#endregion
53
- export { Biome, Command, DEFAULT_CONFIG_PATH, Filter, HUSKY_HOOK_PATH, Handler, LegacySavvyLintHygieneDef, MARKDOWNLINT_CONFIG, MARKDOWNLINT_CONFIG_PATH, MARKDOWNLINT_SCHEMA, MARKDOWNLINT_TEMPLATE, Markdown, POST_CHECKOUT_HOOK_PATH, POST_COMMIT_HOOK_PATH, POST_MERGE_HOOK_PATH, PackageJson, PnpmWorkspace, Preset, SavvyLintSectionDef, ShellScripts, TypeScript, Yaml, createConfig, generateManagedContent, getWorkspacePackagePaths, getWorkspacePackages, getWorkspaceRoot, isWorkspacePackagePath, lint_exports, resetWorkspaceCache, savvyLintBlock };
60
+ export { Biome, Command, DEFAULT_CONFIG_PATH, Filter, HUSKY_HOOK_PATH, Handler, LegacySavvyLintHygieneDef, MARKDOWNLINT_CONFIG, MARKDOWNLINT_CONFIG_PATH, MARKDOWNLINT_SCHEMA, MARKDOWNLINT_TEMPLATE, Markdown, POST_CHECKOUT_HOOK_PATH, POST_COMMIT_HOOK_PATH, POST_MERGE_HOOK_PATH, PackageJson, PnpmWorkspace, Preset, SavvyLintSectionDef, ShellScripts, TextFileFinding, TextFileStagedReadError, TextFileStagedReadErrorBase, TextFileViolation, TextFileViolationReason, TextFiles, TypeScript, Yaml, createConfig, generateManagedContent, getWorkspacePackagePaths, getWorkspacePackages, getWorkspaceRoot, isWorkspacePackagePath, lint_exports, resetWorkspaceCache, savvyLintBlock };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/silk-effects",
3
- "version": "9.2.1",
3
+ "version": "9.3.0",
4
4
  "private": false,
5
5
  "description": "Shared Effect library for Silk Suite conventions",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/silk-effects",
@@ -41,9 +41,9 @@
41
41
  "@effected/glob": "^0.9.0",
42
42
  "@effected/jsonc": "^0.14.0",
43
43
  "@effected/markdown": "^0.14.0",
44
- "@effected/package-json": "^0.19.0",
44
+ "@effected/package-json": "^0.19.1",
45
45
  "@effected/toml": "^0.10.0",
46
- "@effected/walker": "^0.13.0",
46
+ "@effected/walker": "^0.14.0",
47
47
  "@effected/yaml": "^0.18.0",
48
48
  "@manypkg/get-packages": "^3.1.0",
49
49
  "@savvy-web/silk-core": "0.4.8",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@effected/commands": "^0.10.0",
62
- "@effected/git": "^0.18.0",
62
+ "@effected/git": "^0.19.0",
63
63
  "@effected/templates": "^0.9.0",
64
64
  "@effected/workspaces": "^0.30.0",
65
65
  "effect": "4.0.0-rc.118"
@@ -7,7 +7,7 @@ import { Schema } from "effect";
7
7
  * (`.repos/<name>`) without escaping the `.repos/` directory.
8
8
  * @public
9
9
  */
10
- const RepoName = Schema.String.check(Schema.isPattern(/^(?!\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]*$/)).annotate({ identifier: "RepoName" });
10
+ const RepoName = Schema.String.check(Schema.isPattern(/^(?!\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]*$/u)).annotate({ identifier: "RepoName" });
11
11
  /**
12
12
  * An agent-appended note: a discovered answer stamped with the pin it was written against.
13
13
  * @public