@zgeoff/chex 0.0.0 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -3
- package/lefthook.yml +123 -0
- package/package.json +31 -3
- package/src/build-init-package.ts +57 -0
- package/src/build-init-plan.ts +96 -0
- package/src/build-lefthook-extends.ts +117 -0
- package/src/check-names.ts +18 -0
- package/src/cli.ts +49 -0
- package/src/collect-command-names.ts +297 -0
- package/src/collect-expression-references.ts +17 -0
- package/src/dependency-gates/collect-stale-age-gate-excludes.ts +83 -0
- package/src/dependency-gates/collect-unneeded-audit-ignores.ts +41 -0
- package/src/dependency-gates/collect-unneeded-overrides.ts +70 -0
- package/src/dependency-gates/first-party-scopes.ts +1 -0
- package/src/dependency-gates/is-record.ts +4 -0
- package/src/dependency-gates/parse-audit-advisories.ts +33 -0
- package/src/dependency-gates/parse-audit-ignore-ids.ts +15 -0
- package/src/dependency-gates/parse-fixable-packages.ts +29 -0
- package/src/dependency-gates/parse-lockfile-dependents.ts +77 -0
- package/src/dependency-gates/parse-root-manifest.ts +68 -0
- package/src/dependency-gates/read-dependency-gate-findings.ts +91 -0
- package/src/dependency-gates/run-dependency-gates-check.ts +54 -0
- package/src/dependency-gates/types.ts +19 -0
- package/src/docs/check-length.sh +117 -0
- package/src/docs/check-links.sh +245 -0
- package/src/docs/check-prose.sh +657 -0
- package/src/docs/check-readmes.sh +102 -0
- package/src/docs/check-skill.sh +413 -0
- package/src/docs/check-vision.sh +34 -0
- package/src/find-install-cache-path.ts +16 -0
- package/src/format-commit-lines.ts +3 -0
- package/src/make-check-runner.ts +40 -0
- package/src/parse-added-lines.ts +112 -0
- package/src/parse-chex-args.ts +76 -0
- package/src/parse-lane-limits.ts +59 -0
- package/src/parse-pushed-refs.ts +29 -0
- package/src/parse-shell-words.ts +520 -0
- package/src/pick-merge-blobs-command.ts +152 -0
- package/src/read-branch-base.ts +31 -0
- package/src/read-branch-files.ts +59 -0
- package/src/read-pushed-ranges.ts +47 -0
- package/src/read-pushed-refs-input.ts +18 -0
- package/src/read-range-commits.ts +30 -0
- package/src/run-chex.ts +62 -0
- package/src/run-commits-check.ts +178 -0
- package/src/run-init.ts +129 -0
- package/src/run-local-paths-check.ts +185 -0
- package/src/run-program.ts +38 -0
- package/src/run-test-timeouts-check.ts +498 -0
- package/src/run-uncommitted-check.ts +82 -0
- package/src/run-workflows-check.ts +610 -0
- package/src/shell-escape-parsers.ts +197 -0
- package/src/write-text-file.ts +7 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export interface AddedLine {
|
|
2
|
+
readonly hash: string;
|
|
3
|
+
|
|
4
|
+
// Relative to the repo root.
|
|
5
|
+
readonly path: string;
|
|
6
|
+
|
|
7
|
+
// The line's number in the file as the commit leaves it.
|
|
8
|
+
readonly line: number;
|
|
9
|
+
|
|
10
|
+
readonly text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The patch is `git log -p -U0 --format=%x00%h` output: each commit starts with a NUL and its hash,
|
|
14
|
+
// which no line of a patch can start with.
|
|
15
|
+
export function parseAddedLines(patch: string): AddedLine[] {
|
|
16
|
+
const hunkHeader = /^@@ -\d+(?:,\d+)? \+(?<start>\d+)(?:,\d+)? @@/u;
|
|
17
|
+
const added: AddedLine[] = [];
|
|
18
|
+
let hash = '';
|
|
19
|
+
let path: string | null = null;
|
|
20
|
+
let line = 0;
|
|
21
|
+
|
|
22
|
+
// A file's header runs from its `diff --git` line to its first hunk. Inside a hunk, an added line
|
|
23
|
+
// that starts with `++` reads like a `+++` header line, so the state tells the two apart.
|
|
24
|
+
let inHeader = false;
|
|
25
|
+
|
|
26
|
+
for (const text of patch.split('\n')) {
|
|
27
|
+
if (text.startsWith('\0')) {
|
|
28
|
+
hash = text.slice(1);
|
|
29
|
+
path = null;
|
|
30
|
+
inHeader = false;
|
|
31
|
+
} else if (text.startsWith('diff --git ')) {
|
|
32
|
+
path = null;
|
|
33
|
+
inHeader = true;
|
|
34
|
+
} else if (inHeader && text.startsWith('+++ ')) {
|
|
35
|
+
path = parseNewPath(text.slice('+++ '.length));
|
|
36
|
+
} else if (hunkHeader.test(text)) {
|
|
37
|
+
line = Number(hunkHeader.exec(text)?.groups?.['start']);
|
|
38
|
+
inHeader = false;
|
|
39
|
+
} else if (!inHeader && path !== null && text.startsWith('+')) {
|
|
40
|
+
added.push({ hash, path, line, text: text.slice(1) });
|
|
41
|
+
|
|
42
|
+
line += 1;
|
|
43
|
+
} else if (!inHeader && path !== null && text.startsWith(' ')) {
|
|
44
|
+
// A repo's diff.interHunkContext joins nearby hunks with the unchanged lines between them,
|
|
45
|
+
// even under -U0.
|
|
46
|
+
line += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return added;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `b/path`, or `/dev/null` for a file the commit deletes. git ends the name with a tab when it holds
|
|
54
|
+
// a space, and quotes it in C style when it holds a tab, a newline, a quote or a backslash.
|
|
55
|
+
function parseNewPath(name: string): string | null {
|
|
56
|
+
const bare = name.replace(/\t$/u, '');
|
|
57
|
+
|
|
58
|
+
if (bare === '/dev/null') {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const unquoted =
|
|
63
|
+
bare.startsWith('"') && bare.endsWith('"') ? parseQuotedName(bare.slice(1, -1)) : bare;
|
|
64
|
+
|
|
65
|
+
return unquoted.replace(/^b\//u, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ESCAPED_BYTES: ReadonlyMap<string, number> = new Map([
|
|
69
|
+
['a', 0x07],
|
|
70
|
+
['b', 0x08],
|
|
71
|
+
['t', 0x09],
|
|
72
|
+
['n', 0x0a],
|
|
73
|
+
['v', 0x0b],
|
|
74
|
+
['f', 0x0c],
|
|
75
|
+
['r', 0x0d],
|
|
76
|
+
['"', 0x22],
|
|
77
|
+
['\\', 0x5c],
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
// git writes each escaped byte as a letter or as three octal digits, and the bytes are UTF-8.
|
|
81
|
+
function parseQuotedName(quoted: string): string {
|
|
82
|
+
const bytes: number[] = [];
|
|
83
|
+
|
|
84
|
+
const encoder = new TextEncoder();
|
|
85
|
+
|
|
86
|
+
for (let index = 0; index < quoted.length; index += 1) {
|
|
87
|
+
// A character outside the Basic Multilingual Plane, such as an emoji, spans two code units.
|
|
88
|
+
const character = String.fromCodePoint(quoted.codePointAt(index) ?? 0);
|
|
89
|
+
|
|
90
|
+
if (character !== '\\') {
|
|
91
|
+
bytes.push(...encoder.encode(character));
|
|
92
|
+
|
|
93
|
+
index += character.length - 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const next = quoted.charAt(index + 1);
|
|
98
|
+
const octal = /^[0-7]{3}/u.exec(quoted.slice(index + 1))?.[0];
|
|
99
|
+
|
|
100
|
+
if (octal === undefined) {
|
|
101
|
+
bytes.push(ESCAPED_BYTES.get(next) ?? next.codePointAt(0) ?? 0x5c);
|
|
102
|
+
|
|
103
|
+
index += 1;
|
|
104
|
+
} else {
|
|
105
|
+
bytes.push(Number.parseInt(octal, 8));
|
|
106
|
+
|
|
107
|
+
index += 3;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
112
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import type { CheckName } from './check-names.ts';
|
|
3
|
+
import { checkNames, isCheckName } from './check-names.ts';
|
|
4
|
+
|
|
5
|
+
export type ChexRequest =
|
|
6
|
+
| { readonly kind: 'init' }
|
|
7
|
+
| {
|
|
8
|
+
readonly kind: 'checks';
|
|
9
|
+
readonly names: readonly CheckName[];
|
|
10
|
+
readonly args: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
| { readonly kind: 'refused'; readonly message: string };
|
|
13
|
+
|
|
14
|
+
const checksWithArgs: ReadonlySet<CheckName> = new Set([
|
|
15
|
+
'prose',
|
|
16
|
+
'links',
|
|
17
|
+
'skill',
|
|
18
|
+
'commits',
|
|
19
|
+
'local-paths',
|
|
20
|
+
'workflows',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export const chexUsage = `usage: chex <check>... | chex <check> [args...] | chex init
|
|
24
|
+
checks: ${checkNames.join(', ')}`;
|
|
25
|
+
|
|
26
|
+
export function parseChexArgs(argv: readonly string[]): ChexRequest {
|
|
27
|
+
const [first, ...rest] = argv;
|
|
28
|
+
|
|
29
|
+
if (first === undefined) {
|
|
30
|
+
return { kind: 'refused', message: 'name a check to run' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (first === 'init') {
|
|
34
|
+
return rest.length === 0
|
|
35
|
+
? { kind: 'init' }
|
|
36
|
+
: { kind: 'refused', message: 'init takes no arguments' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Each check parses its own options, so the tokens only mark where the check names end.
|
|
40
|
+
const tokens = parseArgs({
|
|
41
|
+
args: [...argv],
|
|
42
|
+
strict: false,
|
|
43
|
+
allowPositionals: true,
|
|
44
|
+
tokens: true,
|
|
45
|
+
}).tokens;
|
|
46
|
+
|
|
47
|
+
const firstArg = tokens.find((token) => token.kind !== 'positional' || !isCheckName(token.value));
|
|
48
|
+
|
|
49
|
+
const names = (firstArg === undefined ? argv : argv.slice(0, firstArg.index)).filter((word) =>
|
|
50
|
+
isCheckName(word),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const args = firstArg === undefined ? [] : argv.slice(firstArg.index);
|
|
54
|
+
const [only, ...others] = names;
|
|
55
|
+
|
|
56
|
+
if (only === undefined) {
|
|
57
|
+
return { kind: 'refused', message: `unknown check "${first}"` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (args.length === 0) {
|
|
61
|
+
return { kind: 'checks', names: [...new Set(names)], args };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (others.length > 0) {
|
|
65
|
+
return {
|
|
66
|
+
kind: 'refused',
|
|
67
|
+
message: `"${args[0]}" is no check name; arguments follow one check name alone`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!checksWithArgs.has(only)) {
|
|
72
|
+
return { kind: 'refused', message: `${only} takes no arguments, and got "${args[0]}"` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { kind: 'checks', names, args };
|
|
76
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The limits match the max-test-timeout rule in @zgeoff/oxlint-config, which CI cannot import: the
|
|
2
|
+
// workflow downloads this file and the modules it imports one by one.
|
|
3
|
+
const standardLaneLimits: readonly (readonly [string, number])[] = [
|
|
4
|
+
['browser', 10_000],
|
|
5
|
+
['e2e', 15_000],
|
|
6
|
+
['docker', 15_000],
|
|
7
|
+
['host', 15_000],
|
|
8
|
+
['agent-sdk', 5000],
|
|
9
|
+
['model-api', 120_000],
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const listedLaneLimitMs = 15_000;
|
|
13
|
+
|
|
14
|
+
export function parseLaneLimits(configText: string | null): ReadonlyMap<string, number> {
|
|
15
|
+
const limits = new Map(standardLaneLimits);
|
|
16
|
+
|
|
17
|
+
const lanes = configText === null ? undefined : parseLanesOption(configText);
|
|
18
|
+
|
|
19
|
+
if (Array.isArray(lanes)) {
|
|
20
|
+
for (const lane of lanes) {
|
|
21
|
+
if (typeof lane === 'string' && !limits.has(lane)) {
|
|
22
|
+
limits.set(lane, listedLaneLimitMs);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} else if (typeof lanes === 'object' && lanes !== null) {
|
|
26
|
+
for (const [lane, limitMs] of Object.entries(lanes)) {
|
|
27
|
+
if (typeof limitMs === 'number') {
|
|
28
|
+
limits.set(lane, limitMs);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return limits;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseLanesOption(text: string): unknown {
|
|
37
|
+
const config = parseConfig(text);
|
|
38
|
+
const setting = findMember(findMember(config, 'rules'), 'zgeoff/max-test-timeout');
|
|
39
|
+
const ruleOptions: unknown = Array.isArray(setting) ? setting[1] : undefined;
|
|
40
|
+
|
|
41
|
+
return findMember(ruleOptions, 'lanes');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseConfig(text: string): unknown {
|
|
45
|
+
try {
|
|
46
|
+
return Bun.JSONC.parse(text);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`cannot parse .oxlintrc.json: ${error instanceof Error ? error.message : String(error)}`,
|
|
50
|
+
{ cause: error },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findMember(value: unknown, key: string): unknown {
|
|
56
|
+
return typeof value === 'object' && value !== null && Object.hasOwn(value, key)
|
|
57
|
+
? Reflect.get(value, key)
|
|
58
|
+
: undefined;
|
|
59
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface PushedRef {
|
|
2
|
+
readonly localSha: string;
|
|
3
|
+
|
|
4
|
+
// Null when the push creates the remote ref.
|
|
5
|
+
readonly remoteSha: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const zeroSha = /^0+$/u;
|
|
9
|
+
|
|
10
|
+
// git writes one `<local ref> <local sha> <remote ref> <remote sha>` line per ref it pushes.
|
|
11
|
+
export function parsePushedRefs(text: string): PushedRef[] {
|
|
12
|
+
return text
|
|
13
|
+
.split('\n')
|
|
14
|
+
.filter((line) => line.trim() !== '')
|
|
15
|
+
.flatMap((line) => {
|
|
16
|
+
const [, localSha, , remoteSha] = line.trim().split(' ');
|
|
17
|
+
|
|
18
|
+
if (localSha === undefined || remoteSha === undefined) {
|
|
19
|
+
throw new Error(`cannot read the pushed ref line: ${line}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A push that deletes the remote ref sends no commit.
|
|
23
|
+
if (zeroSha.test(localSha)) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return [{ localSha, remoteSha: zeroSha.test(remoteSha) ? null : remoteSha }];
|
|
28
|
+
});
|
|
29
|
+
}
|