@zgeoff/chex 0.0.13 → 0.0.14
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 +1 -1
- package/package.json +1 -1
- package/src/ci-plan/parse-docs-checks.ts +37 -0
- package/src/ci-plan/plan-check-scripts.ts +76 -0
- package/src/ci-plan/write-check-plan.ts +123 -0
- package/src/cli.ts +2 -0
- package/src/parse-chex-args.ts +8 -1
- package/src/run-chex.ts +2 -0
- package/src/write-step-outputs.ts +21 -0
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ skipped file back:
|
|
|
83
83
|
`chex init` writes the `extends` line and a
|
|
84
84
|
[`chex-baseline` job](../../docs/architecture/pre-push-baseline.md#a-checkout-without-the-baseline)
|
|
85
85
|
into `lefthook.yml`, and adds a `check:docs` script of `chex prose length links skill` when the repo
|
|
86
|
-
has none
|
|
86
|
+
has none, and changes nothing on a second run. `chex ci-plan` plans a run of `bun-checks`.
|
|
87
87
|
|
|
88
88
|
## The pre-push baseline
|
|
89
89
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { match } from 'ts-pattern';
|
|
2
|
+
import { docsCheckNames } from '../check-names.ts';
|
|
3
|
+
import type { ChexRequest } from '../parse-chex-args.ts';
|
|
4
|
+
import { parseChexArgs } from '../parse-chex-args.ts';
|
|
5
|
+
|
|
6
|
+
export type DocsChecks =
|
|
7
|
+
| { readonly kind: 'none' }
|
|
8
|
+
| { readonly kind: 'named'; readonly checks: readonly string[] }
|
|
9
|
+
| { readonly kind: 'refused'; readonly message: string };
|
|
10
|
+
|
|
11
|
+
// CI runs each docs check that the repo's check:docs script names as its own step, so the script
|
|
12
|
+
// holds chex and the names alone, as chex init writes it.
|
|
13
|
+
export function parseDocsChecks(script: string | undefined): DocsChecks {
|
|
14
|
+
if (script === undefined) {
|
|
15
|
+
return { kind: 'none' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const [program, ...words] = script.trim().split(/\s+/u);
|
|
19
|
+
|
|
20
|
+
const refusal: DocsChecks = {
|
|
21
|
+
kind: 'refused',
|
|
22
|
+
message: `package.json runs "${script}" as check:docs; write it as chex and the docs checks it takes, from ${docsCheckNames.join(', ')}, such as "chex prose length links", so CI runs each check as its own step.`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return match<ChexRequest, DocsChecks>(parseChexArgs(words))
|
|
26
|
+
.with({ kind: 'checks' }, (request) =>
|
|
27
|
+
program === 'chex' &&
|
|
28
|
+
request.args.length === 0 &&
|
|
29
|
+
request.names.every((name) => docsCheckNames.some((docsName) => docsName === name))
|
|
30
|
+
? { kind: 'named', checks: request.names }
|
|
31
|
+
: refusal,
|
|
32
|
+
)
|
|
33
|
+
.with({ kind: 'init' }, () => refusal)
|
|
34
|
+
.with({ kind: 'ci-plan' }, () => refusal)
|
|
35
|
+
.with({ kind: 'refused' }, () => refusal)
|
|
36
|
+
.exhaustive();
|
|
37
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type CheckPart = 'all' | 'static' | 'test' | 'lane';
|
|
2
|
+
|
|
3
|
+
export interface CheckPlanRequest {
|
|
4
|
+
readonly scripts: string;
|
|
5
|
+
|
|
6
|
+
readonly defined: readonly string[];
|
|
7
|
+
|
|
8
|
+
readonly part: CheckPart;
|
|
9
|
+
|
|
10
|
+
readonly lane: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type CheckPlan =
|
|
14
|
+
| {
|
|
15
|
+
readonly kind: 'planned';
|
|
16
|
+
readonly scripts: readonly string[];
|
|
17
|
+
readonly static: readonly string[];
|
|
18
|
+
readonly test: readonly string[];
|
|
19
|
+
readonly lanes: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
| { readonly kind: 'missing'; readonly missing: readonly string[] };
|
|
22
|
+
|
|
23
|
+
export function planCheckScripts(request: CheckPlanRequest): CheckPlan {
|
|
24
|
+
const defined = new Set(request.defined);
|
|
25
|
+
|
|
26
|
+
const listed = request.scripts.split(/\s+/u).filter((name) => name !== '');
|
|
27
|
+
const list = listed.length === 0 ? buildStandardList(defined) : listed;
|
|
28
|
+
const missing = list.filter((name) => !defined.has(name));
|
|
29
|
+
|
|
30
|
+
if (missing.length > 0) {
|
|
31
|
+
return { kind: 'missing', missing };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lanes = list.filter((name) => isLaneScript(name)).map((name) => name.slice('test:'.length));
|
|
35
|
+
const tests = list.filter((name) => isTestScript(name) && !isLaneScript(name));
|
|
36
|
+
const staticChecks = list.filter((name) => !isTestScript(name));
|
|
37
|
+
|
|
38
|
+
const parts: Record<CheckPart, readonly string[]> = {
|
|
39
|
+
all: [...staticChecks, ...tests, ...lanes.map((lane) => `test:${lane}`)],
|
|
40
|
+
static: staticChecks,
|
|
41
|
+
test: tests,
|
|
42
|
+
lane: [`test:${request.lane}`],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
kind: 'planned',
|
|
47
|
+
scripts: parts[request.part],
|
|
48
|
+
static: staticChecks,
|
|
49
|
+
test: tests,
|
|
50
|
+
lanes,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const laneNames = ['e2e', 'browser', 'docker', 'host', 'agent-sdk', 'crash'];
|
|
55
|
+
|
|
56
|
+
function buildStandardList(defined: ReadonlySet<string>): string[] {
|
|
57
|
+
const optional = (name: string) => (defined.has(name) ? [name] : []);
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
'audit',
|
|
61
|
+
'deadcode',
|
|
62
|
+
'format:check',
|
|
63
|
+
'lint',
|
|
64
|
+
'typecheck',
|
|
65
|
+
'test',
|
|
66
|
+
...laneNames.flatMap((lane) => optional(`test:${lane}`)),
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isLaneScript(name: string): boolean {
|
|
71
|
+
return laneNames.some((lane) => name === `test:${lane}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isTestScript(name: string): boolean {
|
|
75
|
+
return name === 'test' || name.startsWith('test:');
|
|
76
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { appendFile, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
4
|
+
import type { StepOutputFile } from '../write-step-outputs.ts';
|
|
5
|
+
import { writeStepOutputs } from '../write-step-outputs.ts';
|
|
6
|
+
import { parseDocsChecks } from './parse-docs-checks.ts';
|
|
7
|
+
import type { CheckPart, CheckPlan } from './plan-check-scripts.ts';
|
|
8
|
+
import { planCheckScripts } from './plan-check-scripts.ts';
|
|
9
|
+
|
|
10
|
+
export interface CheckPlanStep {
|
|
11
|
+
readonly cwd: string;
|
|
12
|
+
|
|
13
|
+
readonly outputPath: string;
|
|
14
|
+
|
|
15
|
+
readonly part: string;
|
|
16
|
+
|
|
17
|
+
readonly scripts: string;
|
|
18
|
+
|
|
19
|
+
readonly lane: string;
|
|
20
|
+
|
|
21
|
+
readonly readText: (path: string) => Promise<string>;
|
|
22
|
+
|
|
23
|
+
readonly outputFile: StepOutputFile;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const parts: readonly CheckPart[] = ['all', 'static', 'test', 'lane'];
|
|
27
|
+
|
|
28
|
+
// Returns the errors that fail the step; with none, the plan is in the step outputs.
|
|
29
|
+
export async function writeCheckPlan(step: Readonly<CheckPlanStep>): Promise<string[]> {
|
|
30
|
+
const part = parts.find((candidate) => candidate === step.part);
|
|
31
|
+
|
|
32
|
+
if (part === undefined || step.outputPath === '') {
|
|
33
|
+
return [`the plan step needs PART (one of ${parts.join(', ')}) and GITHUB_OUTPUT.`];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const packageText = await step.readText(join(step.cwd, 'package.json'));
|
|
37
|
+
|
|
38
|
+
const packageJSON: unknown = JSON.parse(packageText);
|
|
39
|
+
const scripts = findScripts(packageJSON);
|
|
40
|
+
const docsScript = scripts['check:docs'];
|
|
41
|
+
const docsText = typeof docsScript === 'string' ? docsScript : undefined;
|
|
42
|
+
|
|
43
|
+
const plan = planCheckScripts({
|
|
44
|
+
scripts: step.scripts,
|
|
45
|
+
defined: Object.keys(scripts),
|
|
46
|
+
part,
|
|
47
|
+
lane: step.lane,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// A repo with no check:docs gets an empty docs output, which runs the docs step that reads the
|
|
51
|
+
// docs scripts repo-sync delivers.
|
|
52
|
+
return match(parseDocsChecks(docsText))
|
|
53
|
+
.with({ kind: 'refused' }, (docs) => Promise.resolve([docs.message]))
|
|
54
|
+
.with({ kind: 'none' }, () => writePlanOutcome(plan, '', step))
|
|
55
|
+
.with({ kind: 'named' }, (docs) => writePlanOutcome(plan, ` ${docs.checks.join(' ')} `, step))
|
|
56
|
+
.exhaustive();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findScripts(value: unknown): Readonly<Record<string, unknown>> {
|
|
60
|
+
if (typeof value !== 'object' || value === null || !('scripts' in value)) {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const scripts = value.scripts;
|
|
65
|
+
|
|
66
|
+
return typeof scripts === 'object' && scripts !== null ? { ...scripts } : {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type CheckPlanOf<Kind extends CheckPlan['kind']> = Extract<CheckPlan, { readonly kind: Kind }>;
|
|
70
|
+
|
|
71
|
+
type PlanOutcomeWriter<Kind extends CheckPlan['kind']> = (
|
|
72
|
+
plan: CheckPlanOf<Kind>,
|
|
73
|
+
docs: string,
|
|
74
|
+
step: Readonly<CheckPlanStep>,
|
|
75
|
+
) => Promise<string[]>;
|
|
76
|
+
|
|
77
|
+
// A table typed over every plan kind, so a new kind fails the type check until it has an outcome.
|
|
78
|
+
const planOutcomeWriters: { readonly [Kind in CheckPlan['kind']]: PlanOutcomeWriter<Kind> } = {
|
|
79
|
+
missing: (plan) =>
|
|
80
|
+
Promise.resolve([
|
|
81
|
+
`package.json defines no script named: ${plan.missing.join(' ')}. Pass the scripts input, or set the BUN_CHECK_SCRIPTS repo variable, to change the list.`,
|
|
82
|
+
]),
|
|
83
|
+
planned: async (plan, docs, step) => {
|
|
84
|
+
// Spaces around each list let a later step match one whole name.
|
|
85
|
+
await writeStepOutputs(step.outputFile, step.outputPath, {
|
|
86
|
+
scripts: ` ${plan.scripts.join(' ')} `,
|
|
87
|
+
static: ` ${plan.static.join(' ')} `,
|
|
88
|
+
test: ` ${plan.test.join(' ')} `,
|
|
89
|
+
lanes: JSON.stringify(plan.lanes),
|
|
90
|
+
docs,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return [];
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function writePlanOutcome<Kind extends CheckPlan['kind']>(
|
|
98
|
+
plan: CheckPlanOf<Kind>,
|
|
99
|
+
docs: string,
|
|
100
|
+
step: Readonly<CheckPlanStep>,
|
|
101
|
+
): Promise<string[]> {
|
|
102
|
+
const writeOutcome: PlanOutcomeWriter<Kind> = planOutcomeWriters[plan.kind];
|
|
103
|
+
|
|
104
|
+
return writeOutcome(plan, docs, step);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function runCIPlanCommand(): Promise<number> {
|
|
108
|
+
const errors = await writeCheckPlan({
|
|
109
|
+
cwd: process.cwd(),
|
|
110
|
+
outputPath: process.env['GITHUB_OUTPUT'] ?? '',
|
|
111
|
+
part: process.env['PART'] ?? '',
|
|
112
|
+
scripts: process.env['SCRIPTS'] ?? '',
|
|
113
|
+
lane: process.env['LANE'] ?? '',
|
|
114
|
+
readText: (path) => readFile(path, 'utf8'),
|
|
115
|
+
outputFile: { appendText: (path, text) => appendFile(path, text) },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
for (const error of errors) {
|
|
119
|
+
process.stdout.write(`::error::${error}\n`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return errors.length === 0 ? 0 : 1;
|
|
123
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { rename, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { readOptionalText } from '@zgeoff/std';
|
|
5
|
+
import { runCIPlanCommand } from './ci-plan/write-check-plan.ts';
|
|
5
6
|
import { runDependencyGatesCommand } from './dependency-gates/run-dependency-gates-check.ts';
|
|
6
7
|
import { makeCheckRunner } from './make-check-runner.ts';
|
|
7
8
|
import { runChex } from './run-chex.ts';
|
|
@@ -42,6 +43,7 @@ process.exitCode = await runChex(process.argv.slice(2), {
|
|
|
42
43
|
process.stderr.write(text);
|
|
43
44
|
},
|
|
44
45
|
}),
|
|
46
|
+
runCIPlan: runCIPlanCommand,
|
|
45
47
|
writeOutput: (text) => {
|
|
46
48
|
process.stdout.write(text);
|
|
47
49
|
},
|
package/src/parse-chex-args.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { checkNames, isCheckName } from './check-names.ts';
|
|
|
4
4
|
|
|
5
5
|
export type ChexRequest =
|
|
6
6
|
| { readonly kind: 'init' }
|
|
7
|
+
| { readonly kind: 'ci-plan' }
|
|
7
8
|
| {
|
|
8
9
|
readonly kind: 'checks';
|
|
9
10
|
readonly names: readonly CheckName[];
|
|
@@ -20,7 +21,7 @@ const checksWithArgs: ReadonlySet<CheckName> = new Set([
|
|
|
20
21
|
'workflows',
|
|
21
22
|
]);
|
|
22
23
|
|
|
23
|
-
export const chexUsage = `usage: chex <check>... | chex <check> [args...] | chex init
|
|
24
|
+
export const chexUsage = `usage: chex <check>... | chex <check> [args...] | chex init | chex ci-plan
|
|
24
25
|
checks: ${checkNames.join(', ')}`;
|
|
25
26
|
|
|
26
27
|
export function parseChexArgs(argv: readonly string[]): ChexRequest {
|
|
@@ -36,6 +37,12 @@ export function parseChexArgs(argv: readonly string[]): ChexRequest {
|
|
|
36
37
|
: { kind: 'refused', message: 'init takes no arguments' };
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
if (first === 'ci-plan') {
|
|
41
|
+
return rest.length === 0
|
|
42
|
+
? { kind: 'ci-plan' }
|
|
43
|
+
: { kind: 'refused', message: 'ci-plan takes no arguments' };
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
// Each check parses its own options, so the tokens only mark where the check names end.
|
|
40
47
|
const tokens = parseArgs({
|
|
41
48
|
args: [...argv],
|
package/src/run-chex.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { chexUsage, parseChexArgs } from './parse-chex-args.ts';
|
|
|
5
5
|
export interface ChexDeps {
|
|
6
6
|
readonly runCheck: (name: CheckName, args: readonly string[]) => Promise<number>;
|
|
7
7
|
readonly runInit: () => Promise<number>;
|
|
8
|
+
readonly runCIPlan: () => Promise<number>;
|
|
8
9
|
readonly writeOutput: (text: string) => void;
|
|
9
10
|
readonly writeError: (text: string) => void;
|
|
10
11
|
}
|
|
@@ -19,6 +20,7 @@ export function runChex(argv: readonly string[], deps: ChexDeps): Promise<number
|
|
|
19
20
|
return Promise.resolve(2);
|
|
20
21
|
})
|
|
21
22
|
.with({ kind: 'init' }, () => deps.runInit())
|
|
23
|
+
.with({ kind: 'ci-plan' }, () => deps.runCIPlan())
|
|
22
24
|
.with({ kind: 'checks' }, (request) => runChecks(request.names, request.args, deps))
|
|
23
25
|
.exhaustive();
|
|
24
26
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface StepOutputFile {
|
|
2
|
+
readonly appendText: (path: string, text: string) => Promise<void>;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// GitHub Actions reads each `name=value` line of the file that GITHUB_OUTPUT names as one step
|
|
6
|
+
// output, so a value that holds a newline would end its output early.
|
|
7
|
+
export async function writeStepOutputs(
|
|
8
|
+
outputFile: StepOutputFile,
|
|
9
|
+
outputPath: string,
|
|
10
|
+
outputs: Readonly<Record<string, string>>,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const lines = Object.entries(outputs).map(([name, value]) => {
|
|
13
|
+
if (value.includes('\n')) {
|
|
14
|
+
throw new Error(`the step output ${name} holds a newline`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return `${name}=${value}\n`;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await outputFile.appendText(outputPath, lines.join(''));
|
|
21
|
+
}
|