@savvy-web/silk-effects 9.2.2 → 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.
- package/changesets/services/branch-analyzer.js +1 -0
- package/changesets/services/deps-regen.js +24 -10
- package/changesets/services/release-planner.js +1 -0
- package/changesets/utils/git.js +23 -1
- package/index.d.ts +276 -9
- package/lint/config/Preset.js +5 -0
- package/lint/config/createConfig.js +5 -0
- package/lint/handlers/TextFiles.js +391 -0
- package/lint/index.js +8 -1
- package/package.json +3 -3
|
@@ -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,
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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) {
|
package/changesets/utils/git.js
CHANGED
|
@@ -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
|
package/lint/config/Preset.js
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@effected/markdown": "^0.14.0",
|
|
44
44
|
"@effected/package-json": "^0.19.1",
|
|
45
45
|
"@effected/toml": "^0.10.0",
|
|
46
|
-
"@effected/walker": "^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.
|
|
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"
|