@avi2dg/checks 0.56.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/docs/bins.md +7 -5
- package/docs/configs/dependency-rules.md +3 -3
- package/docs/configs/native-settings.md +5 -5
- package/docs/design.md +4 -2
- package/docs/gates/checks-frontend-syntax.md +2 -2
- package/docs/gates/checks-graphwalk.md +126 -0
- package/docs/gates/checks-lint-coverage.md +2 -1
- package/docs/gates/checks-lint.md +10 -5
- package/docs/gates/checks-night-run.md +38 -9
- package/docs/gates/checks-no-plain-js.md +1 -1
- package/docs/gates/checks-quarantine-clock.md +4 -2
- package/docs/gates/checks-repetition.md +12 -7
- package/docs/gates/checks-suppressions-ratchet.md +5 -2
- package/package.json +4 -7
- package/src/complexity/graphwalk.ts +374 -0
- package/src/complexity/knip.ts +2 -17
- package/src/complexity/repetition.ts +51 -16
- package/src/complexity/suppressions-ratchet.ts +14 -5
- package/src/core/gates.ts +13 -8
- package/src/core/git.ts +3 -1
- package/src/core/lint.ts +54 -20
- package/src/core/main.ts +12 -8
- package/src/testing/night-run-findings.ts +20 -3
- package/src/testing/night-run.ts +13 -7
- package/src/testing/quarantine-clock.ts +10 -2
- package/docs/gates/checks-exports.md +0 -104
- package/docs/gates/checks-imports.md +0 -64
- package/docs/gates/checks-unused.md +0 -66
- package/src/complexity/exports.ts +0 -189
- package/src/complexity/unused.ts +0 -49
- package/src/dependencies/imports.ts +0 -94
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "avi2d",
|
|
@@ -90,8 +90,7 @@
|
|
|
90
90
|
"src/quality/browser/",
|
|
91
91
|
"src/complexity/suppressions-ratchet.ts",
|
|
92
92
|
"src/complexity/repetition.ts",
|
|
93
|
-
"src/complexity/
|
|
94
|
-
"src/complexity/exports.ts",
|
|
93
|
+
"src/complexity/graphwalk.ts",
|
|
95
94
|
"src/complexity/knip.ts",
|
|
96
95
|
"src/testing/quarantine-clock.ts",
|
|
97
96
|
"src/docs/sentence-length.ts",
|
|
@@ -121,7 +120,6 @@
|
|
|
121
120
|
"src/quality/presets/stylelint.ts",
|
|
122
121
|
"src/quality/effect-scope.ts",
|
|
123
122
|
"src/quality/jsonc-patch.ts",
|
|
124
|
-
"src/dependencies/imports.ts",
|
|
125
123
|
"src/dependencies/kit-defaults.ts",
|
|
126
124
|
"oxlintrc.json",
|
|
127
125
|
"stryker.preset.js",
|
|
@@ -188,8 +186,7 @@
|
|
|
188
186
|
"checks-css-size": "src/quality/css-size.ts",
|
|
189
187
|
"checks-suppressions-ratchet": "src/complexity/suppressions-ratchet.ts",
|
|
190
188
|
"checks-repetition": "src/complexity/repetition.ts",
|
|
191
|
-
"checks-
|
|
192
|
-
"checks-exports": "src/complexity/exports.ts",
|
|
189
|
+
"checks-graphwalk": "src/complexity/graphwalk.ts",
|
|
193
190
|
"checks-quarantine-clock": "src/testing/quarantine-clock.ts",
|
|
194
191
|
"checks-docs": "src/docs/docs.ts",
|
|
195
192
|
"checks-copy": "src/docs/copy.ts",
|
|
@@ -201,7 +198,6 @@
|
|
|
201
198
|
"checks-frontend-syntax": "src/quality/frontend-syntax.ts",
|
|
202
199
|
"checks-no-plain-js": "src/quality/no-plain-js.ts",
|
|
203
200
|
"checks-browser": "src/quality/browser/browser.ts",
|
|
204
|
-
"checks-imports": "src/dependencies/imports.ts",
|
|
205
201
|
"checks-effect-scope": "src/quality/effect-scope.ts"
|
|
206
202
|
},
|
|
207
203
|
"scripts": {
|
|
@@ -214,6 +210,7 @@
|
|
|
214
210
|
"test:fast": "bun scripts/test-fast.ts",
|
|
215
211
|
"test:unit": "bun src/testing/test.ts --tier=unit",
|
|
216
212
|
"test:e2e": "bun src/testing/test.ts --tier=e2e --parallel=4",
|
|
213
|
+
"test:live": "bun src/testing/test.ts --tier=live",
|
|
217
214
|
"mutate": "bunx stryker run",
|
|
218
215
|
"mutate:incremental": "bunx stryker run --incremental"
|
|
219
216
|
},
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { BunServices } from "@effect/platform-bun";
|
|
3
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
4
|
+
import { LINTED_SOURCE, TYPESCRIPT_SOURCE, type TrackedContent } from "../core/gates.ts";
|
|
5
|
+
import { checkoutFiles, collect, commitOf, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
|
|
6
|
+
import { PASSED, runExitMain, UNDECIDED, Usage, VIOLATED } from "../core/main.ts";
|
|
7
|
+
import { knipReport } from "./knip.ts";
|
|
8
|
+
|
|
9
|
+
const NAME = "graphwalk";
|
|
10
|
+
const UNUSED = "unused";
|
|
11
|
+
const EXPORTS = "exports";
|
|
12
|
+
const IMPORTS = "imports";
|
|
13
|
+
const WRITE = "--write";
|
|
14
|
+
const USAGE = `usage: graphwalk.ts [<ref> | <base-ref> <head-ref> | ${WRITE}]`;
|
|
15
|
+
|
|
16
|
+
const INCLUDED = ["--include", "files,exports,types"];
|
|
17
|
+
const JUDGED = /[.]tsx?$|[.]astro$/;
|
|
18
|
+
|
|
19
|
+
export const BASELINE_FILE = "exports-baseline.json";
|
|
20
|
+
|
|
21
|
+
export const PROJECT_CONFIG = "dependency-cruiser.config.ts";
|
|
22
|
+
|
|
23
|
+
export const OWN_CONFIGS = [
|
|
24
|
+
".dependency-cruiser.json",
|
|
25
|
+
".dependency-cruiser.js",
|
|
26
|
+
".dependency-cruiser.cjs",
|
|
27
|
+
".dependency-cruiser.mjs",
|
|
28
|
+
".dependency-cruiser.ts",
|
|
29
|
+
".dependency-cruiser.cts",
|
|
30
|
+
".dependency-cruiser.mts",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export const CRUISED = ["*.ts", "*.tsx", "*.mts", "*.cts"] as const;
|
|
34
|
+
|
|
35
|
+
const KIT_DEFAULTS = "kit-defaults.ts";
|
|
36
|
+
|
|
37
|
+
export type Status = "passed" | "violated" | "undecided";
|
|
38
|
+
|
|
39
|
+
class GraphwalkError extends Schema.TaggedError<GraphwalkError>()("GraphwalkError", {
|
|
40
|
+
message: Schema.String,
|
|
41
|
+
}) {}
|
|
42
|
+
|
|
43
|
+
const UnusedSymbolSchema = Schema.Struct({ file: Schema.String, kind: Schema.Literals(["export", "type"]), name: Schema.String });
|
|
44
|
+
|
|
45
|
+
export type UnusedSymbol = typeof UnusedSymbolSchema.Type;
|
|
46
|
+
|
|
47
|
+
export type Baseline = readonly UnusedSymbol[];
|
|
48
|
+
|
|
49
|
+
export type Drift = { readonly unlisted: readonly UnusedSymbol[]; readonly added: readonly UnusedSymbol[]; readonly stale: readonly UnusedSymbol[] };
|
|
50
|
+
|
|
51
|
+
const Violation = Schema.Struct({ from: Schema.String, to: Schema.String, rule: Schema.Struct({ name: Schema.String, severity: Schema.String }) });
|
|
52
|
+
|
|
53
|
+
export type Violation = typeof Violation.Type;
|
|
54
|
+
|
|
55
|
+
const decodeKnipFiles = Schema.decodeUnknownEffect(
|
|
56
|
+
Schema.fromJsonString(Schema.Struct({ issues: Schema.Array(Schema.Struct({ files: Schema.optional(Schema.Array(Schema.Struct({ name: Schema.String }))) })) })),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const decodeKnipSymbols = Schema.decodeUnknownEffect(
|
|
60
|
+
Schema.fromJsonString(Schema.Struct({ issues: Schema.Array(Schema.Struct({ file: Schema.String, exports: Schema.optional(Schema.Array(Schema.Struct({ name: Schema.String }))), types: Schema.optional(Schema.Array(Schema.Struct({ name: Schema.String }))) })) })),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const decodeBaseline = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(UnusedSymbolSchema)));
|
|
64
|
+
|
|
65
|
+
const decodeCruise = Schema.decodeUnknownEffect(
|
|
66
|
+
Schema.fromJsonString(Schema.Struct({ summary: Schema.Struct({ violations: Schema.Array(Violation), error: Schema.Int, totalCruised: Schema.Int }) })),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const byPosition = (a: UnusedSymbol, b: UnusedSymbol): number => a.file.localeCompare(b.file) || a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
|
|
70
|
+
|
|
71
|
+
const keyOf = ({ file, kind, name }: UnusedSymbol): string => `${kind}\0${file}\0${name}`;
|
|
72
|
+
|
|
73
|
+
function outside(symbols: readonly UnusedSymbol[], others: readonly UnusedSymbol[]): readonly UnusedSymbol[] {
|
|
74
|
+
const known = new Set(others.map(keyOf));
|
|
75
|
+
return symbols.filter((symbol) => !known.has(keyOf(symbol))).toSorted(byPosition);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const describe = (symbol: UnusedSymbol): string => ` ${symbol.file}: ${symbol.name} (${symbol.kind})`;
|
|
79
|
+
|
|
80
|
+
export const filesOf = Effect.fn("filesOf")(function* (stdout: string) {
|
|
81
|
+
const { issues } = yield* decodeKnipFiles(stdout).pipe(
|
|
82
|
+
Effect.mapError((cause) => new GraphwalkError({ message: `knip's JSON report does not decode: ${cause.message}` })),
|
|
83
|
+
);
|
|
84
|
+
return issues
|
|
85
|
+
.flatMap(({ files = [] }) => files.map(({ name }) => name))
|
|
86
|
+
.filter((file) => JUDGED.test(file))
|
|
87
|
+
.toSorted();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export function unusedReport({ tracked, files }: { readonly tracked: number; readonly files: readonly string[] }): string {
|
|
91
|
+
if (files.length === 0) return `${UNUSED}: no unreferenced files among ${tracked} tracked .ts/.tsx/.astro file(s)`;
|
|
92
|
+
return [`${UNUSED}: ${files.length} unreferenced file(s):`, ...files.map((file) => ` ${file}`)].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const symbolsOf = Effect.fn("symbolsOf")(function* (stdout: string) {
|
|
96
|
+
const { issues } = yield* decodeKnipSymbols(stdout).pipe(
|
|
97
|
+
Effect.mapError((cause) => new GraphwalkError({ message: `knip's JSON report does not decode: ${cause.message}` })),
|
|
98
|
+
);
|
|
99
|
+
return issues
|
|
100
|
+
.flatMap(({ file, exports = [], types = [] }): readonly UnusedSymbol[] => [
|
|
101
|
+
...exports.map(({ name }): UnusedSymbol => ({ file, kind: "export", name })),
|
|
102
|
+
...types.map(({ name }): UnusedSymbol => ({ file, kind: "type", name })),
|
|
103
|
+
])
|
|
104
|
+
.toSorted(byPosition);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const baselineOf = Effect.fn("baselineOf")(function* (text: string, where: string) {
|
|
108
|
+
return yield* decodeBaseline(text).pipe(
|
|
109
|
+
Effect.mapError((cause) => new GraphwalkError({ message: `${where} does not decode: ${cause.message}` })),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export function driftOf({
|
|
114
|
+
reported,
|
|
115
|
+
base,
|
|
116
|
+
head,
|
|
117
|
+
unusedAtBase,
|
|
118
|
+
}: {
|
|
119
|
+
readonly reported: Baseline;
|
|
120
|
+
readonly base: Baseline;
|
|
121
|
+
readonly head: Baseline;
|
|
122
|
+
readonly unusedAtBase: Baseline;
|
|
123
|
+
}): Drift {
|
|
124
|
+
return {
|
|
125
|
+
unlisted: outside(reported, head),
|
|
126
|
+
added: outside(head, [...base, ...unusedAtBase]),
|
|
127
|
+
stale: outside(head, reported),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function exportsReport(head: Baseline, { unlisted, added, stale }: Drift): string {
|
|
132
|
+
const sections = [
|
|
133
|
+
{ symbols: unlisted, what: `unused export(s) not in ${BASELINE_FILE}:` },
|
|
134
|
+
{ symbols: added, what: `${BASELINE_FILE} export(s) the range adds that its base did not leave unused, remove the export instead:` },
|
|
135
|
+
{ symbols: stale, what: `${BASELINE_FILE} export(s) no longer reported, remove them:` },
|
|
136
|
+
].filter(({ symbols }) => symbols.length > 0);
|
|
137
|
+
if (sections.length > 0) {
|
|
138
|
+
return sections.flatMap(({ symbols, what }) => [`${EXPORTS}: ${symbols.length} ${what}`, ...symbols.map(describe)]).join("\n");
|
|
139
|
+
}
|
|
140
|
+
return head.length === 0
|
|
141
|
+
? `${EXPORTS}: no unused exports or types`
|
|
142
|
+
: `${EXPORTS}: ${head.length} unused export(s) in ${BASELINE_FILE}, and no new ones`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function describeViolation({ from, to, rule }: Violation): string {
|
|
146
|
+
return from === to ? ` ${rule.severity} ${rule.name}: ${from}` : ` ${rule.severity} ${rule.name}: ${from} → ${to}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const unconfiguredLine = (section: string): string =>
|
|
150
|
+
`${section}: no knip configuration names entry files, so add a knip.config.ts calling defineConfig from @avi2dg/checks/knip`;
|
|
151
|
+
|
|
152
|
+
const trackedIn = Effect.fn("trackedIn")(function* (root: string, source: TrackedContent) {
|
|
153
|
+
return (yield* git(["ls-files", "--", ...source.pathspecs], root))
|
|
154
|
+
.split("\n")
|
|
155
|
+
.map((line) => line.trim())
|
|
156
|
+
.filter((line) => line !== "");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const baselinePath = Effect.fn("baselinePath")(function* (root: string) {
|
|
160
|
+
return (yield* Path.Path).join(root, BASELINE_FILE);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const baselineInTree = Effect.fn("baselineInTree")(function* (root: string) {
|
|
164
|
+
const fs = yield* FileSystem.FileSystem;
|
|
165
|
+
const at = yield* baselinePath(root);
|
|
166
|
+
if (!(yield* fs.exists(at))) return [] satisfies Baseline;
|
|
167
|
+
return yield* fs.readFileString(at).pipe(Effect.flatMap((text) => baselineOf(text, BASELINE_FILE)));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const baselineAt = Effect.fn("baselineAt")(function* (treeish: string, root: string) {
|
|
171
|
+
const blob = (yield* git(["ls-tree", "--object-only", treeish, "--", BASELINE_FILE], root)).trim();
|
|
172
|
+
if (blob === "") return [] satisfies Baseline;
|
|
173
|
+
return yield* baselineOf(yield* git(["cat-file", "blob", blob], root), `${BASELINE_FILE} at ${treeish}`);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Knip loads the configuration's imports, such as the kit's knip builder, from a node_modules the checkout lacks.
|
|
177
|
+
const unusedAt = Effect.fn("unusedAt")(
|
|
178
|
+
function* (rev: string, root: string) {
|
|
179
|
+
const files = yield* pathsAt(rev, [], root);
|
|
180
|
+
if (files.length === 0) return [] satisfies Baseline;
|
|
181
|
+
const fs = yield* FileSystem.FileSystem;
|
|
182
|
+
const path = yield* Path.Path;
|
|
183
|
+
const tree = yield* checkoutFiles(rev, files, yield* fs.makeTempDirectoryScoped({ prefix: "checks-graphwalk-" }), root);
|
|
184
|
+
const modules = path.join(root, "node_modules");
|
|
185
|
+
if (yield* fs.exists(modules)) yield* fs.symlink(modules, path.join(tree, "node_modules"));
|
|
186
|
+
const reported = yield* knipReport(tree, INCLUDED).pipe(
|
|
187
|
+
Effect.mapError((cause) => new GraphwalkError({ message: `at ${rev}: ${cause.message}` })),
|
|
188
|
+
);
|
|
189
|
+
return reported.kind === "unconfigured" ? [] : yield* symbolsOf(reported.stdout);
|
|
190
|
+
},
|
|
191
|
+
Effect.scoped,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Knip scans the checked-out tree, and a pull request's merge checkout holds the base branch tip beside the range head.
|
|
195
|
+
const judgedBase = Effect.fn("judgedBase")(function* (first: string, second: string | undefined, root: string) {
|
|
196
|
+
const { base } = yield* rangeEnds(first, second, root);
|
|
197
|
+
if (second === undefined) return base;
|
|
198
|
+
const head = yield* commitOf(second, root);
|
|
199
|
+
const parents = (yield* git(["rev-parse", "HEAD^@"], root)).split("\n").filter((line) => line !== "");
|
|
200
|
+
const [other, ...rest] = parents.filter((parent) => parent !== head);
|
|
201
|
+
return parents.includes(head) && other !== undefined && rest.length === 0 ? other : base;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const configOf = Effect.fn("configOf")(function* (root: string) {
|
|
205
|
+
const fs = yield* FileSystem.FileSystem;
|
|
206
|
+
const path = yield* Path.Path;
|
|
207
|
+
for (const name of [PROJECT_CONFIG, ...OWN_CONFIGS]) {
|
|
208
|
+
if (yield* fs.exists(path.join(root, name))) return { file: name, shown: name };
|
|
209
|
+
}
|
|
210
|
+
return { file: path.join(import.meta.dir, "..", "dependencies", KIT_DEFAULTS), shown: "the kit's defaults" };
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const depcruise = Effect.fn("depcruise")(function* () {
|
|
214
|
+
const path = yield* Path.Path;
|
|
215
|
+
const main = yield* Effect.try({
|
|
216
|
+
try: () => new URL("../../bin/dependency-cruiser.mjs", import.meta.resolve("dependency-cruiser")),
|
|
217
|
+
catch: () => new GraphwalkError({ message: "cannot resolve dependency-cruiser, a peer dependency of the kit" }),
|
|
218
|
+
});
|
|
219
|
+
return yield* path.fromFileUrl(main);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const trackedInTree = Effect.fn("trackedInTree")(function* (root: string) {
|
|
223
|
+
const fs = yield* FileSystem.FileSystem;
|
|
224
|
+
const path = yield* Path.Path;
|
|
225
|
+
const listed = yield* git(["ls-files", "-z", "--", ...CRUISED], root);
|
|
226
|
+
return yield* Effect.filter(listed.split("\0").filter(Boolean), (file) => fs.exists(path.join(root, file)), { concurrency: "unbounded" });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const runSection = Effect.fn("runSection")(function* (
|
|
230
|
+
section: string,
|
|
231
|
+
body: Effect.Effect<Status, { readonly message: string }, BunServices.BunServices>,
|
|
232
|
+
) {
|
|
233
|
+
return yield* body.pipe(
|
|
234
|
+
Effect.catch((cause) => Console.error(`${section}: ${cause.message}`).pipe(Effect.as<Status>("undecided"))),
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const unusedSection = (tracked: number, stdout: string) =>
|
|
239
|
+
runSection(
|
|
240
|
+
UNUSED,
|
|
241
|
+
Effect.gen(function* () {
|
|
242
|
+
const files = yield* filesOf(stdout);
|
|
243
|
+
yield* Console.log(unusedReport({ tracked, files }));
|
|
244
|
+
return files.length === 0 ? "passed" : "violated";
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const exportsSection = (root: string, stdout: string, first: string, second: string | undefined) =>
|
|
249
|
+
runSection(
|
|
250
|
+
EXPORTS,
|
|
251
|
+
Effect.gen(function* () {
|
|
252
|
+
const head = yield* baselineInTree(root);
|
|
253
|
+
const reported = yield* symbolsOf(stdout);
|
|
254
|
+
const baseRev = yield* judgedBase(first, second, root);
|
|
255
|
+
const base = yield* baselineAt(baseRev, root);
|
|
256
|
+
const unusedAtBase = outside(head, base).length === 0 ? [] : yield* unusedAt(baseRev, root);
|
|
257
|
+
const drift = driftOf({ reported, base, head, unusedAtBase });
|
|
258
|
+
yield* Console.log(exportsReport(head, drift));
|
|
259
|
+
return drift.unlisted.length === 0 && drift.added.length === 0 && drift.stale.length === 0 ? "passed" : "violated";
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const importsSection = (root: string) =>
|
|
264
|
+
runSection(
|
|
265
|
+
IMPORTS,
|
|
266
|
+
Effect.gen(function* () {
|
|
267
|
+
const files = yield* trackedInTree(root);
|
|
268
|
+
if (files.length === 0) return yield* new GraphwalkError({ message: "no tracked TypeScript to cruise" });
|
|
269
|
+
const config = yield* configOf(root);
|
|
270
|
+
const run = yield* collect(process.execPath, [yield* depcruise(), "--config", config.file, "--output-type", "json", ...files], root).pipe(
|
|
271
|
+
Effect.mapError((cause) => new GraphwalkError({ message: `cannot run dependency-cruiser: ${cause.message}` })),
|
|
272
|
+
);
|
|
273
|
+
const { summary } = yield* decodeCruise(run.stdout).pipe(
|
|
274
|
+
Effect.mapError(
|
|
275
|
+
() => new GraphwalkError({ message: `dependency-cruiser exits ${run.exitCode} with no report: ${(run.stdout + run.stderr).trim()}` }),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
const cruised = `${summary.totalCruised} module(s) cruised against ${config.shown}`;
|
|
279
|
+
if (summary.violations.length === 0) {
|
|
280
|
+
yield* Console.log(`${IMPORTS}: ${cruised}, no violation`);
|
|
281
|
+
return "passed";
|
|
282
|
+
}
|
|
283
|
+
yield* Console.error(
|
|
284
|
+
[`${IMPORTS}: ${summary.violations.length} violation(s) in ${cruised}`, ...summary.violations.map(describeViolation)].join("\n"),
|
|
285
|
+
);
|
|
286
|
+
return summary.error === 0 ? "passed" : "violated";
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
function exitOf(statuses: readonly Status[]): number {
|
|
291
|
+
if (statuses.includes("violated")) return VIOLATED;
|
|
292
|
+
if (statuses.includes("undecided")) return UNDECIDED;
|
|
293
|
+
return PASSED;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const seed = Effect.fn("seed")(function* (root: string, tracked: number) {
|
|
297
|
+
if (tracked === 0) return yield* new GraphwalkError({ message: "no tracked TypeScript source to scan" });
|
|
298
|
+
const reported = yield* knipReport(root, INCLUDED).pipe(Effect.mapError((cause) => new GraphwalkError({ message: cause.message })));
|
|
299
|
+
if (reported.kind === "unconfigured") {
|
|
300
|
+
yield* Console.log(unconfiguredLine(EXPORTS));
|
|
301
|
+
return VIOLATED;
|
|
302
|
+
}
|
|
303
|
+
const symbols = yield* symbolsOf(reported.stdout);
|
|
304
|
+
yield* (yield* FileSystem.FileSystem).writeFileString(yield* baselinePath(root), `${JSON.stringify(symbols, null, 2)}\n`);
|
|
305
|
+
yield* Console.log(`${EXPORTS}: wrote ${symbols.length} unused export(s) to ${BASELINE_FILE}`);
|
|
306
|
+
return PASSED;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const lintOnlyGate = Effect.fn("lintOnlyGate")(function* (root: string, args: readonly string[]) {
|
|
310
|
+
if ((yield* trackedInTree(root)).length === 0) return yield* new GraphwalkError({ message: "no tracked TypeScript or Astro source to scan" });
|
|
311
|
+
if (args.length > 0) yield* refArgs(args, USAGE);
|
|
312
|
+
return exitOf([yield* importsSection(root)]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const knipFailedGate = Effect.fn("knipFailedGate")(function* (root: string, message: string, hasTypescript: boolean) {
|
|
316
|
+
yield* Console.error(`${UNUSED}: ${message}`);
|
|
317
|
+
if (!hasTypescript) {
|
|
318
|
+
if ((yield* trackedInTree(root)).length === 0) return UNDECIDED;
|
|
319
|
+
return exitOf(["undecided", yield* importsSection(root)]);
|
|
320
|
+
}
|
|
321
|
+
yield* Console.error(`${EXPORTS}: ${message}`);
|
|
322
|
+
return exitOf(["undecided", "undecided", yield* importsSection(root)]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const unconfiguredGate = Effect.fn("unconfiguredGate")(function* (root: string, hasTypescript: boolean) {
|
|
326
|
+
yield* Console.log(unconfiguredLine(UNUSED));
|
|
327
|
+
yield* Console.log(unconfiguredLine(EXPORTS));
|
|
328
|
+
const cruised = hasTypescript || (yield* trackedInTree(root)).length > 0 ? yield* importsSection(root) : "passed";
|
|
329
|
+
return exitOf(["violated", "violated", cruised]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const judgedGate = Effect.fn("judgedGate")(function* (root: string, linted: number, typescript: number, stdout: string, first: string, second: string | undefined) {
|
|
333
|
+
const unlisted = yield* unusedSection(linted, stdout);
|
|
334
|
+
if (typescript === 0) {
|
|
335
|
+
if ((yield* trackedInTree(root)).length === 0) return exitOf([unlisted]);
|
|
336
|
+
return exitOf([unlisted, yield* importsSection(root)]);
|
|
337
|
+
}
|
|
338
|
+
const drifted = yield* exportsSection(root, stdout, first, second);
|
|
339
|
+
return exitOf([unlisted, drifted, yield* importsSection(root)]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const gate = Effect.gen(function* () {
|
|
343
|
+
const args = process.argv.slice(2);
|
|
344
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
345
|
+
if (args.length === 1 && args[0] === WRITE) {
|
|
346
|
+
const tracked = yield* trackedIn(root, TYPESCRIPT_SOURCE);
|
|
347
|
+
return yield* seed(root, tracked.length).pipe(
|
|
348
|
+
Effect.catch((cause: { readonly message: string }) => Console.error(`${EXPORTS}: ${cause.message}`).pipe(Effect.as(UNDECIDED))),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
const linted = yield* trackedIn(root, LINTED_SOURCE);
|
|
352
|
+
if (linted.length === 0) return yield* lintOnlyGate(root, args);
|
|
353
|
+
const { first, second } = args.length === 0 ? { first: "HEAD", second: undefined } : yield* refArgs(args, USAGE);
|
|
354
|
+
const typescript = yield* trackedIn(root, TYPESCRIPT_SOURCE);
|
|
355
|
+
const knipOutcome = yield* knipReport(root, INCLUDED).pipe(
|
|
356
|
+
Effect.mapError((cause) => new GraphwalkError({ message: cause.message })),
|
|
357
|
+
Effect.map((reported) => ({ failed: false as const, reported })),
|
|
358
|
+
Effect.catch((cause: { readonly message: string }) => Effect.succeed({ failed: true as const, message: cause.message })),
|
|
359
|
+
);
|
|
360
|
+
if (knipOutcome.failed) return yield* knipFailedGate(root, knipOutcome.message, typescript.length > 0);
|
|
361
|
+
const reported = knipOutcome.reported;
|
|
362
|
+
if (reported.kind === "unconfigured") return yield* unconfiguredGate(root, typescript.length > 0);
|
|
363
|
+
return yield* judgedGate(root, linted.length, typescript.length, reported.stdout, first, second);
|
|
364
|
+
}).pipe(
|
|
365
|
+
Effect.catch((cause: { readonly message: string }) =>
|
|
366
|
+
(cause instanceof Usage ? Console.error(`${NAME}: ${cause.message}`) : Console.error(`${UNUSED}: ${cause.message}`)).pipe(
|
|
367
|
+
Effect.as(UNDECIDED),
|
|
368
|
+
),
|
|
369
|
+
),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
if (import.meta.main) {
|
|
373
|
+
runExitMain(gate);
|
|
374
|
+
}
|
package/src/complexity/knip.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import { collect, git } from "../core/git.ts";
|
|
1
|
+
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
import { collect } from "../core/git.ts";
|
|
4
3
|
|
|
5
4
|
class KnipError extends Schema.TaggedError<KnipError>()("KnipError", {
|
|
6
5
|
message: Schema.String,
|
|
@@ -53,17 +52,3 @@ export const knipReport = Effect.fn("knipReport")(function* (root: string, args:
|
|
|
53
52
|
}
|
|
54
53
|
return { kind: "reported", stdout: run.stdout } as const;
|
|
55
54
|
});
|
|
56
|
-
|
|
57
|
-
export const scanTree = Effect.fn("scanTree")(function* (name: string, args: readonly string[], source: TrackedContent) {
|
|
58
|
-
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
59
|
-
const tracked = (yield* git(["ls-files", "--", ...source.pathspecs], root))
|
|
60
|
-
.split("\n")
|
|
61
|
-
.map((line) => line.trim())
|
|
62
|
-
.filter((line) => line !== "");
|
|
63
|
-
if (tracked.length === 0) return yield* new KnipError({ message: `no tracked ${source.content} to scan` });
|
|
64
|
-
const reported = yield* knipReport(root, args);
|
|
65
|
-
if (reported.kind === "unconfigured") {
|
|
66
|
-
yield* Console.log(`${name}: no knip configuration names entry files, so add a knip.config.ts calling defineConfig from @avi2dg/checks/knip`);
|
|
67
|
-
}
|
|
68
|
-
return { root, tracked: tracked.length, reported };
|
|
69
|
-
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
-
import { changedPaths, checkoutFiles, collect, git, pathsAt, rangeFromArgs } from "../core/git.ts";
|
|
4
|
-
import {
|
|
3
|
+
import { changedPaths, checkoutFiles, collect, commitOf, git, pathsAt, rangeFromArgs } from "../core/git.ts";
|
|
4
|
+
import { WHOLE_TREE } from "../core/gates.ts";
|
|
5
|
+
import { Usage, runMain } from "../core/main.ts";
|
|
5
6
|
|
|
6
7
|
export type Fragment = {
|
|
7
8
|
readonly file: string;
|
|
@@ -29,7 +30,7 @@ class JscpdUnreadable extends Schema.TaggedError<JscpdUnreadable>()("JscpdUnread
|
|
|
29
30
|
}) {}
|
|
30
31
|
|
|
31
32
|
const NAME = "repetition";
|
|
32
|
-
const USAGE = "usage: repetition.ts <ref> | <base-ref> <head-ref>";
|
|
33
|
+
const USAGE = "usage: repetition.ts <ref> | <base-ref> <head-ref> | --whole-tree <head-ref>";
|
|
33
34
|
const MIN_TOKENS = 50;
|
|
34
35
|
const MIN_LINES = 5;
|
|
35
36
|
const MEASURE = `at ${MIN_TOKENS} tokens and ${MIN_LINES} lines`;
|
|
@@ -164,31 +165,65 @@ const runHold = Effect.fn("runHold")(function* (root: string, config: string, ba
|
|
|
164
165
|
return heldOf(sources, rises, after) satisfies Held;
|
|
165
166
|
});
|
|
166
167
|
|
|
168
|
+
const runWholeTree = Effect.fn("runWholeTree")(function* (root: string, config: string, head: string) {
|
|
169
|
+
const { sources, clones } = yield* scan(root, head, config, []);
|
|
170
|
+
const after = repeatedLines(clones);
|
|
171
|
+
const rises = risesOf([...after.keys()], new Map(), after, new Map(), clones);
|
|
172
|
+
return heldOf(sources, rises, after) satisfies Held;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
function cloneLines(clones: readonly Clone[]): readonly string[] {
|
|
176
|
+
return clones.map(([own, other]) => ` ${own.file}:${own.start}-${own.end} repeats ${other.file}:${other.start}-${other.end}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
167
179
|
export function describe({ file, before, after, clones }: Rise): readonly string[] {
|
|
168
|
-
return [
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
return [` ${file}: ${after} repeated line(s), up from ${before}`, ...cloneLines(clones)];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function wholeTreeRise({ file, after, clones }: Rise): readonly string[] {
|
|
184
|
+
return [` ${file}: ${after} repeated line(s)`, ...cloneLines(clones)];
|
|
172
185
|
}
|
|
173
186
|
|
|
187
|
+
const noticeOf = (advisory: Held["advisory"]): readonly string[] =>
|
|
188
|
+
advisory.size === 0
|
|
189
|
+
? []
|
|
190
|
+
: [
|
|
191
|
+
`${NAME}: advisory, ${advisory.size} file(s) repeat lines the hold does not fail:`,
|
|
192
|
+
...[...advisory].map(([file, lines]) => ` ${file}: ${lines} repeated line(s)`),
|
|
193
|
+
];
|
|
194
|
+
|
|
174
195
|
export function report({ measured, rises, advisory }: Held): string {
|
|
175
196
|
const verdict =
|
|
176
197
|
rises.length === 0
|
|
177
198
|
? [`${NAME}: ${measured} file(s) ${CONFIG} holds repeat no more lines than where the range starts, ${MEASURE}`]
|
|
178
199
|
: [`${NAME}: ${rises.length} file(s) ${CONFIG} holds repeat more lines than where the range starts, ${MEASURE}:`, ...rises.flatMap(describe)];
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
200
|
+
return [...verdict, ...noticeOf(advisory)].join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function reportWholeTree({ measured, rises }: Held): string {
|
|
204
|
+
const verdict =
|
|
205
|
+
rises.length === 0
|
|
206
|
+
? [`${NAME}: ${measured} file(s) ${CONFIG} holds repeat no lines at the head, ${MEASURE}`]
|
|
207
|
+
: [`${NAME}: ${rises.length} file(s) ${CONFIG} holds repeat lines at the head, ${MEASURE}:`, ...rises.flatMap(wholeTreeRise)];
|
|
208
|
+
return verdict.join("\n");
|
|
187
209
|
}
|
|
188
210
|
|
|
189
211
|
const hold = Effect.gen(function* () {
|
|
190
212
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
191
|
-
const
|
|
213
|
+
const args = process.argv.slice(2);
|
|
214
|
+
if (args[0] === WHOLE_TREE) {
|
|
215
|
+
const headArg = args[1];
|
|
216
|
+
if (args.length !== 2 || headArg === undefined) return yield* new Usage({ message: USAGE });
|
|
217
|
+
const wholeHead = yield* commitOf(headArg);
|
|
218
|
+
if ((yield* pathsAt(wholeHead, [`:(literal)${CONFIG}`], root)).length === 0) {
|
|
219
|
+
yield* Console.log(`${NAME}: the head holds no ${CONFIG}, so no file is measured`);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
const whole = yield* runWholeTree(root, yield* git(["show", `${wholeHead}:${CONFIG}`], root), wholeHead);
|
|
223
|
+
yield* Console.log(reportWholeTree(whole));
|
|
224
|
+
return whole.rises.length === 0;
|
|
225
|
+
}
|
|
226
|
+
const { base, head } = yield* rangeFromArgs(args, USAGE, root);
|
|
192
227
|
if ((yield* pathsAt(head, [`:(literal)${CONFIG}`], root)).length === 0) {
|
|
193
228
|
yield* Console.log(`${NAME}: the head holds no ${CONFIG}, so no file is measured`);
|
|
194
229
|
return true;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
|
-
import { git, commitOf, parentOrEmptyTree, refArgs } from "../core/git.ts";
|
|
4
|
-
import {
|
|
3
|
+
import { git, commitOf, emptyTree, parentOrEmptyTree, refArgs } from "../core/git.ts";
|
|
4
|
+
import { WHOLE_TREE } from "../core/gates.ts";
|
|
5
|
+
import { Usage, runMain } from "../core/main.ts";
|
|
5
6
|
|
|
6
7
|
export const SUPPRESSIONS = ["oxlint-suppressions.json", "stylelint-suppressions.json"] as const;
|
|
7
8
|
|
|
@@ -23,7 +24,7 @@ export class SuppressionsError extends Schema.TaggedError<SuppressionsError>()("
|
|
|
23
24
|
message: Schema.String,
|
|
24
25
|
}) {}
|
|
25
26
|
|
|
26
|
-
const USAGE = "usage: suppressions-ratchet.ts <ref> | <base-ref> <head-ref>";
|
|
27
|
+
const USAGE = "usage: suppressions-ratchet.ts <ref> | <base-ref> <head-ref> | --whole-tree <head-ref>";
|
|
27
28
|
|
|
28
29
|
const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
|
|
29
30
|
const decodeObject = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
|
|
@@ -124,11 +125,19 @@ export const runTip = Effect.fn("runTip")(function* (tip: string) {
|
|
|
124
125
|
return yield* compareAt(yield* parentOrEmptyTree(tipCommit), tipCommit);
|
|
125
126
|
});
|
|
126
127
|
|
|
128
|
+
export const runWholeTree = Effect.fn("runWholeTree")(function* (head: string) {
|
|
129
|
+
return yield* compareAt(yield* emptyTree(), yield* commitOf(head));
|
|
130
|
+
});
|
|
131
|
+
|
|
127
132
|
const ratchet = Effect.gen(function* () {
|
|
128
133
|
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
129
|
-
|
|
134
|
+
if (first === WHOLE_TREE) {
|
|
135
|
+
if (second === undefined) return yield* new Usage({ message: USAGE });
|
|
136
|
+
const whole = yield* runWholeTree(second);
|
|
137
|
+
yield* Console.log(report(whole));
|
|
138
|
+
return whole.rises.length === 0;
|
|
139
|
+
}
|
|
130
140
|
const result = second !== undefined ? yield* runRange(first, second) : yield* runTip(first);
|
|
131
|
-
|
|
132
141
|
yield* Console.log(report(result));
|
|
133
142
|
return result.rises.length === 0;
|
|
134
143
|
});
|
package/src/core/gates.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type KitGate = {
|
|
|
26
26
|
readonly args?: readonly string[];
|
|
27
27
|
readonly env?: Readonly<Record<string, string>>;
|
|
28
28
|
readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
|
|
29
|
+
readonly night?: true;
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
export const ENTRY_POINT: Program = { bin: "checks-lint", script: "core/lint.ts" };
|
|
@@ -48,8 +49,10 @@ const STYLESHEET: TrackedContent = { pathspecs: ["*.css"], content: "a styleshee
|
|
|
48
49
|
|
|
49
50
|
const COPY_DECLARATION: TrackedContent = { pathspecs: ["copy.json"], content: "a copy declaration" };
|
|
50
51
|
|
|
52
|
+
export const WHOLE_TREE = "--whole-tree";
|
|
53
|
+
|
|
51
54
|
export const KIT_GATES = [
|
|
52
|
-
{ bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: LINTED_SOURCE },
|
|
55
|
+
{ bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: LINTED_SOURCE, night: true },
|
|
53
56
|
{ bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
54
57
|
{ bin: "checks-commit-identity", vector: "delivery", file: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
55
58
|
{ bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
@@ -57,16 +60,14 @@ export const KIT_GATES = [
|
|
|
57
60
|
{ bin: "checks-frontend-syntax", vector: "quality", file: "frontend-syntax.ts", reads: "tree", appliesTo: FRONTEND_SYNTAX_DECLARATION },
|
|
58
61
|
{ bin: "checks-css-size", vector: "quality", file: "css-size.ts", reads: "tree", appliesTo: STYLESHEET },
|
|
59
62
|
{ bin: "checks-no-plain-js", vector: "quality", file: "no-plain-js.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
60
|
-
{ bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
63
|
+
{ bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY, night: true },
|
|
61
64
|
{ bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
|
|
62
65
|
{ bin: "checks-secrets", vector: "delivery", file: "secrets.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
63
66
|
{ bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", alsoReads: "every agent file at the head commit", appliesTo: EVERY_REPOSITORY },
|
|
64
67
|
{ bin: "checks-copy", vector: "docs", file: "copy.ts", reads: "tree", appliesTo: COPY_DECLARATION },
|
|
65
|
-
{ bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
66
|
-
{ bin: "checks-
|
|
67
|
-
{ bin: "checks-
|
|
68
|
-
{ bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
69
|
-
{ bin: "checks-imports", vector: "dependencies", file: "imports.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
68
|
+
{ bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE, night: true },
|
|
69
|
+
{ bin: "checks-graphwalk", vector: "complexity", file: "graphwalk.ts", reads: "range", appliesTo: LINTED_SOURCE },
|
|
70
|
+
{ bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY, night: true },
|
|
70
71
|
{
|
|
71
72
|
bin: "checks-advisories",
|
|
72
73
|
vector: "dependencies",
|
|
@@ -74,5 +75,9 @@ export const KIT_GATES = [
|
|
|
74
75
|
reads: "range",
|
|
75
76
|
env: { [ADVISORIES_DEFER_SCAN]: "1" },
|
|
76
77
|
appliesTo: BUN_LOCKFILE,
|
|
77
|
-
}
|
|
78
|
+
}
|
|
78
79
|
] as const satisfies readonly KitGate[];
|
|
80
|
+
|
|
81
|
+
export const NIGHT_GATES: readonly KitGate[] = KIT_GATES.filter((gate) => "night" in gate);
|
|
82
|
+
|
|
83
|
+
export const PR_GATES: readonly KitGate[] = KIT_GATES.filter((gate) => !("night" in gate));
|
package/src/core/git.ts
CHANGED
|
@@ -168,7 +168,9 @@ export const changedLines = Effect.fn("changedLines")(function* (
|
|
|
168
168
|
return parseAddedLines(diff);
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
const emptyTree = (
|
|
171
|
+
export const emptyTree = Effect.fn("emptyTree")(function* (cwd?: string) {
|
|
172
|
+
return (yield* git(["hash-object", "-t", "tree", "/dev/null"], cwd)).trim();
|
|
173
|
+
});
|
|
172
174
|
|
|
173
175
|
export const pathsAt = Effect.fn("pathsAt")(function* (rev: string, pathspecs: readonly string[], cwd?: string) {
|
|
174
176
|
const listed = yield* git(["diff", "--name-only", "-z", "--no-renames", yield* emptyTree(cwd), rev, "--", ...pathspecs], cwd);
|