vouchington-tooling 0.13.2 → 0.13.3
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 +5 -0
- package/dist/ast-grep-examples/companion-parity.d.mts +19 -0
- package/dist/ast-grep-examples/companion-parity.mjs +156 -0
- package/dist/ast-grep-examples/index.d.mts +2 -1
- package/dist/ast-grep-examples/index.mjs +1 -0
- package/dist/gha-check-run/cli.d.mts +2 -0
- package/dist/gha-check-run/cli.mjs +81 -0
- package/dist/gha-check-run/github.d.mts +30 -0
- package/dist/gha-check-run/github.mjs +63 -0
- package/dist/gha-check-run/index.d.mts +2 -0
- package/dist/gha-check-run/index.mjs +2 -0
- package/dist/gha-post-review/cli.mjs +1 -1
- package/dist/gha-post-review/github.d.mts +6 -22
- package/dist/gha-post-review/github.mjs +8 -92
- package/dist/gha-post-review/post.d.mts +7 -1
- package/dist/gha-post-review/post.mjs +11 -3
- package/dist/gha-post-review/pull-io.d.mts +13 -0
- package/dist/gha-post-review/pull-io.mjs +88 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +1 -1
- package/dist/scc-complexity/baseline.mjs +5 -2
- package/dist/scc-complexity/constants.d.mts +1 -0
- package/dist/scc-complexity/constants.mjs +1 -0
- package/dist/scc-complexity/index.d.mts +1 -1
- package/dist/scc-complexity/index.mjs +24 -36
- package/dist/scc-complexity/paths.d.mts +2 -1
- package/dist/scc-complexity/paths.mjs +35 -2
- package/dist/scc-complexity/scope-config.d.mts +3 -0
- package/dist/scc-complexity/scope-config.mjs +35 -0
- package/dist/scc-complexity/workflow-command.d.mts +2 -0
- package/dist/scc-complexity/workflow-command.mjs +6 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -93,6 +93,11 @@ ancestor of `HEAD`. `gitleaks-directory-scan` builds and scans isolated staged-i
|
|
|
93
93
|
nonignored-working-tree mirrors with an explicit config; `--directory` selects the repository root.
|
|
94
94
|
`ast-grep-examples` runs native `ast-grep test`, then validates each scoped rule's `files:` and
|
|
95
95
|
`ignores:` examples with project `languageGlobs` replay from its root `--config`.
|
|
96
|
+
`compareAstGrepCompanions` compares recursive `<name>.yml`/`.yaml` and `<name>-tsx` companion
|
|
97
|
+
rules across every YAML field, returns stable JSON-pointer differences, and rejects unsafe or
|
|
98
|
+
ambiguous rule paths. The supplied rule path and every ancestor must be physical directories; YAML
|
|
99
|
+
aliases and non-JSON tagged values are rejected before comparison. Callers own any explicit
|
|
100
|
+
normalization for language-specific differences.
|
|
96
101
|
`ast-grep-pack` prints JSON `{ rules, config }` for the shipped unconditional rule pack. Point a
|
|
97
102
|
consumer `sgconfig.yml` `ruleDirs` at `rules` and keep product-specific YAML locally.
|
|
98
103
|
`gha-workspace-policy` checks tracked workflow and composite-action files in the current repository;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface AstGrepCompanionDifference {
|
|
2
|
+
readonly baseFile: string;
|
|
3
|
+
readonly companionFile: string;
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly base: unknown;
|
|
6
|
+
readonly companion: unknown;
|
|
7
|
+
}
|
|
8
|
+
export interface AstGrepCompanionParityOptions {
|
|
9
|
+
readonly rules: string;
|
|
10
|
+
readonly companionSuffix?: string;
|
|
11
|
+
readonly normalize?: (document: unknown, context: AstGrepCompanionContext) => unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface AstGrepCompanionContext {
|
|
14
|
+
readonly file: string;
|
|
15
|
+
readonly role: 'base' | 'companion';
|
|
16
|
+
}
|
|
17
|
+
export declare function compareCodeUnits(left: string, right: string): number;
|
|
18
|
+
/** Compares recursive `<name>.yml`/`.yaml` rules with `<name>-tsx` companions. */
|
|
19
|
+
export declare function compareAstGrepCompanions(options: AstGrepCompanionParityOptions): AstGrepCompanionDifference[];
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { parse as yamlLoad } from 'yaml';
|
|
4
|
+
const YAML_EXTENSION = /\.ya?ml$/u;
|
|
5
|
+
export function compareCodeUnits(left, right) {
|
|
6
|
+
if (left < right)
|
|
7
|
+
return -1;
|
|
8
|
+
if (left > right)
|
|
9
|
+
return 1;
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
function assertPhysicalPath(path) {
|
|
13
|
+
let ancestor = path;
|
|
14
|
+
while (true) {
|
|
15
|
+
if (fs.lstatSync(ancestor).isSymbolicLink())
|
|
16
|
+
throw new Error('rules: symbolic links are not allowed');
|
|
17
|
+
const parent = dirname(ancestor);
|
|
18
|
+
if (parent === ancestor)
|
|
19
|
+
return;
|
|
20
|
+
ancestor = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function yamlFiles(directory, relative = '') {
|
|
24
|
+
const entries = fs
|
|
25
|
+
.readdirSync(directory, { withFileTypes: true })
|
|
26
|
+
.toSorted((a, b) => compareCodeUnits(a.name, b.name));
|
|
27
|
+
const files = [];
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const file = relative ? `${relative}/${entry.name}` : entry.name;
|
|
30
|
+
if (entry.isSymbolicLink())
|
|
31
|
+
throw new Error(`${file}: symbolic links are not allowed`);
|
|
32
|
+
if (entry.isDirectory())
|
|
33
|
+
files.push(...yamlFiles(join(directory, entry.name), file));
|
|
34
|
+
else if (entry.isFile() && YAML_EXTENSION.test(entry.name))
|
|
35
|
+
files.push(file);
|
|
36
|
+
}
|
|
37
|
+
return files;
|
|
38
|
+
}
|
|
39
|
+
function loadDocument(rules, file) {
|
|
40
|
+
try {
|
|
41
|
+
const document = yamlLoad(fs.readFileSync(join(rules, file), 'utf8'), { maxAliasCount: 0 });
|
|
42
|
+
assertJsonValue(document);
|
|
43
|
+
return document;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new Error(`${file}: invalid YAML: ${String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function stem(file) {
|
|
50
|
+
return file.replace(YAML_EXTENSION, '');
|
|
51
|
+
}
|
|
52
|
+
function pointer(path, key) {
|
|
53
|
+
const segment = String(key).replaceAll('~', '~0').replaceAll('/', '~1');
|
|
54
|
+
return `${path}/${segment}`;
|
|
55
|
+
}
|
|
56
|
+
function isRecord(value) {
|
|
57
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
58
|
+
return false;
|
|
59
|
+
const prototype = Object.getPrototypeOf(value);
|
|
60
|
+
return prototype === null || prototype === Object.prototype;
|
|
61
|
+
}
|
|
62
|
+
function assertJsonValue(value, ancestors = new WeakSet()) {
|
|
63
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
64
|
+
return;
|
|
65
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
66
|
+
return;
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
if (ancestors.has(value))
|
|
69
|
+
throw new Error('unsupported cyclic YAML value');
|
|
70
|
+
ancestors.add(value);
|
|
71
|
+
for (const item of value)
|
|
72
|
+
assertJsonValue(item, ancestors);
|
|
73
|
+
ancestors.delete(value);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (isRecord(value)) {
|
|
77
|
+
if (ancestors.has(value))
|
|
78
|
+
throw new Error('unsupported cyclic YAML value');
|
|
79
|
+
ancestors.add(value);
|
|
80
|
+
for (const item of Object.values(value))
|
|
81
|
+
assertJsonValue(item, ancestors);
|
|
82
|
+
ancestors.delete(value);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
throw new Error('unsupported non-JSON YAML value');
|
|
86
|
+
}
|
|
87
|
+
function compare(base, companion, path, difference) {
|
|
88
|
+
if (Object.is(base, companion))
|
|
89
|
+
return;
|
|
90
|
+
if (Array.isArray(base) && Array.isArray(companion)) {
|
|
91
|
+
const length = Math.max(base.length, companion.length);
|
|
92
|
+
for (let index = 0; index < length; index++)
|
|
93
|
+
compare(base[index], companion[index], pointer(path, index), difference);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (isRecord(base) && isRecord(companion)) {
|
|
97
|
+
for (const key of [...new Set([...Object.keys(base), ...Object.keys(companion)])].toSorted(compareCodeUnits))
|
|
98
|
+
compare(base[key], companion[key], pointer(path, key), difference);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
difference(path, base, companion);
|
|
102
|
+
}
|
|
103
|
+
function assertSuffix(suffix) {
|
|
104
|
+
if (!suffix || /[\\/]/u.test(suffix))
|
|
105
|
+
throw new Error('companionSuffix must be a non-empty filename suffix');
|
|
106
|
+
}
|
|
107
|
+
/** Compares recursive `<name>.yml`/`.yaml` rules with `<name>-tsx` companions. */
|
|
108
|
+
export function compareAstGrepCompanions(options) {
|
|
109
|
+
const suffix = options.companionSuffix ?? '-tsx';
|
|
110
|
+
assertSuffix(suffix);
|
|
111
|
+
const rules = resolve(options.rules);
|
|
112
|
+
assertPhysicalPath(rules);
|
|
113
|
+
const files = yamlFiles(rules);
|
|
114
|
+
const basesByStem = new Map();
|
|
115
|
+
const companions = [];
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const fileStem = stem(file);
|
|
118
|
+
if (fileStem.endsWith(suffix))
|
|
119
|
+
companions.push({ file, baseStem: fileStem.slice(0, -suffix.length) });
|
|
120
|
+
else
|
|
121
|
+
basesByStem.set(fileStem, [...(basesByStem.get(fileStem) ?? []), file]);
|
|
122
|
+
}
|
|
123
|
+
const companionsByStem = new Map();
|
|
124
|
+
for (const { file, baseStem } of companions)
|
|
125
|
+
companionsByStem.set(baseStem, [...(companionsByStem.get(baseStem) ?? []), file]);
|
|
126
|
+
for (const [baseStem, companionFiles] of companionsByStem)
|
|
127
|
+
if (companionFiles.length > 1)
|
|
128
|
+
throw new Error(`${baseStem}: duplicate companion rules: ${companionFiles.toSorted(compareCodeUnits).join(', ')}`);
|
|
129
|
+
const differences = [];
|
|
130
|
+
for (const { file: companionFile, baseStem } of companions) {
|
|
131
|
+
const bases = basesByStem.get(baseStem) ?? [];
|
|
132
|
+
if (!bases.length)
|
|
133
|
+
throw new Error(`${companionFile}: missing base companion`);
|
|
134
|
+
if (bases.length > 1)
|
|
135
|
+
throw new Error(`${companionFile}: duplicate base companions: ${bases.toSorted(compareCodeUnits).join(', ')}`);
|
|
136
|
+
const baseFile = bases[0];
|
|
137
|
+
const normalize = options.normalize ?? ((document) => document);
|
|
138
|
+
const base = normalize(loadDocument(rules, baseFile), { file: baseFile, role: 'base' });
|
|
139
|
+
const companion = normalize(loadDocument(rules, companionFile), {
|
|
140
|
+
file: companionFile,
|
|
141
|
+
role: 'companion',
|
|
142
|
+
});
|
|
143
|
+
assertJsonValue(base);
|
|
144
|
+
assertJsonValue(companion);
|
|
145
|
+
compare(base, companion, '', (path, baseValue, companionValue) => {
|
|
146
|
+
differences.push({
|
|
147
|
+
baseFile,
|
|
148
|
+
companionFile,
|
|
149
|
+
path,
|
|
150
|
+
base: baseValue,
|
|
151
|
+
companion: companionValue,
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return differences;
|
|
156
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { compareAstGrepCompanions } from './companion-parity.mts';
|
|
2
|
+
export type { AstGrepCompanionContext, AstGrepCompanionDifference, AstGrepCompanionParityOptions, } from './companion-parity.mts';
|
|
1
3
|
interface AstGrepResult {
|
|
2
4
|
status: number | null;
|
|
3
5
|
stdout?: string;
|
|
@@ -12,4 +14,3 @@ export interface AstGrepExamplesOptions {
|
|
|
12
14
|
}
|
|
13
15
|
export declare function astGrepExamplesArguments(options: AstGrepExamplesOptions): string[];
|
|
14
16
|
export declare function runAstGrepExamples(options: AstGrepExamplesOptions): number;
|
|
15
|
-
export {};
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import picomatch from 'picomatch';
|
|
6
6
|
import { parse as yamlLoad, stringify as yamlDump } from 'yaml';
|
|
7
|
+
export { compareAstGrepCompanions } from './companion-parity.mjs';
|
|
7
8
|
export function astGrepExamplesArguments(options) {
|
|
8
9
|
return [
|
|
9
10
|
'test',
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { createGhExec } from '../gha-post-review/github.mjs';
|
|
4
|
+
import { completeCheckRun, createCheckRun, CheckRunError, } from './github.mjs';
|
|
5
|
+
function requireEnv(name, env) {
|
|
6
|
+
const value = env[name];
|
|
7
|
+
if (!value)
|
|
8
|
+
throw new CheckRunError(`${name} is required.`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function parseStatus(value) {
|
|
12
|
+
if (value !== 'in_progress' && value !== 'completed') {
|
|
13
|
+
throw new CheckRunError(`STATUS must be "in_progress" or "completed" (got "${value}").`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function parseConclusion(value) {
|
|
18
|
+
if (value !== 'success' && value !== 'neutral') {
|
|
19
|
+
throw new CheckRunError(`CONCLUSION must be "success" or "neutral" (got "${value}").`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function appendOutput(name, value, outputPath) {
|
|
24
|
+
if (!outputPath)
|
|
25
|
+
return;
|
|
26
|
+
appendFileSync(outputPath, `${name}=${value}\n`);
|
|
27
|
+
}
|
|
28
|
+
/** Builds a create-check-run input from env, requiring CONCLUSION only when STATUS is completed. */
|
|
29
|
+
function readCreateInput(env) {
|
|
30
|
+
const status = parseStatus(requireEnv('STATUS', env));
|
|
31
|
+
const shared = {
|
|
32
|
+
repository: requireEnv('GITHUB_REPOSITORY', env),
|
|
33
|
+
name: requireEnv('CHECK_NAME', env),
|
|
34
|
+
headSha: requireEnv('HEAD_SHA', env),
|
|
35
|
+
title: requireEnv('TITLE', env),
|
|
36
|
+
summary: requireEnv('SUMMARY', env),
|
|
37
|
+
};
|
|
38
|
+
if (status === 'completed') {
|
|
39
|
+
return { ...shared, status, conclusion: parseConclusion(requireEnv('CONCLUSION', env)) };
|
|
40
|
+
}
|
|
41
|
+
return { ...shared, status };
|
|
42
|
+
}
|
|
43
|
+
function runCreate(env, exec) {
|
|
44
|
+
const id = createCheckRun(readCreateInput(env), exec);
|
|
45
|
+
appendOutput('check_run_id', id, env.GITHUB_OUTPUT);
|
|
46
|
+
}
|
|
47
|
+
/** No-ops when CHECK_RUN_ID is unset: the create step may have already finalized the check. */
|
|
48
|
+
function runComplete(env, exec) {
|
|
49
|
+
const checkRunId = env.CHECK_RUN_ID ?? '';
|
|
50
|
+
if (checkRunId === '')
|
|
51
|
+
return;
|
|
52
|
+
completeCheckRun({
|
|
53
|
+
repository: requireEnv('GITHUB_REPOSITORY', env),
|
|
54
|
+
checkRunId,
|
|
55
|
+
conclusion: parseConclusion(requireEnv('CONCLUSION', env)),
|
|
56
|
+
title: requireEnv('TITLE', env),
|
|
57
|
+
summary: requireEnv('SUMMARY', env),
|
|
58
|
+
}, exec);
|
|
59
|
+
}
|
|
60
|
+
export function runCheckRunCli(argv = process.argv, env = process.env, exec = createGhExec()) {
|
|
61
|
+
const subcommand = argv[2];
|
|
62
|
+
try {
|
|
63
|
+
if (subcommand === 'create') {
|
|
64
|
+
runCreate(env, exec);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
if (subcommand === 'complete') {
|
|
68
|
+
runComplete(env, exec);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
throw new CheckRunError(`Unknown subcommand "${String(subcommand)}". Use create or complete.`);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/* v8 ignore next 3 */
|
|
79
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
80
|
+
process.exitCode = runCheckRunCli();
|
|
81
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { GhExec } from '../gha-post-review/github.mts';
|
|
2
|
+
export declare class CheckRunError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
export type CheckRunStatus = 'in_progress' | 'completed';
|
|
6
|
+
export type CheckRunConclusion = 'success' | 'neutral';
|
|
7
|
+
export type CreateCheckRunInput = {
|
|
8
|
+
repository: string;
|
|
9
|
+
name: string;
|
|
10
|
+
headSha: string;
|
|
11
|
+
title: string;
|
|
12
|
+
summary: string;
|
|
13
|
+
} & ({
|
|
14
|
+
status: 'in_progress';
|
|
15
|
+
conclusion?: undefined;
|
|
16
|
+
} | {
|
|
17
|
+
status: 'completed';
|
|
18
|
+
conclusion: CheckRunConclusion;
|
|
19
|
+
});
|
|
20
|
+
export type CompleteCheckRunInput = {
|
|
21
|
+
repository: string;
|
|
22
|
+
checkRunId: string;
|
|
23
|
+
conclusion: CheckRunConclusion;
|
|
24
|
+
title: string;
|
|
25
|
+
summary: string;
|
|
26
|
+
};
|
|
27
|
+
/** Creates a GitHub check run, returning the API-assigned check run id. */
|
|
28
|
+
export declare function createCheckRun(input: CreateCheckRunInput, exec: GhExec): string;
|
|
29
|
+
/** Transitions an existing check run to `completed` with a final conclusion. */
|
|
30
|
+
export declare function completeCheckRun(input: CompleteCheckRunInput, exec: GhExec): void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export class CheckRunError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'CheckRunError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
function isFullCommitSha(value) {
|
|
8
|
+
return /^[0-9a-f]{40}$/u.test(value);
|
|
9
|
+
}
|
|
10
|
+
function isCheckRunId(value) {
|
|
11
|
+
return /^[0-9]+$/u.test(value);
|
|
12
|
+
}
|
|
13
|
+
function parseCheckRunId(raw) {
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(raw);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new CheckRunError('Check run creation response was not valid JSON.');
|
|
20
|
+
}
|
|
21
|
+
const id = parsed !== null && typeof parsed === 'object' ? parsed.id : undefined;
|
|
22
|
+
if (typeof id !== 'number' && typeof id !== 'string') {
|
|
23
|
+
throw new CheckRunError('Check run creation response did not include an id.');
|
|
24
|
+
}
|
|
25
|
+
return String(id);
|
|
26
|
+
}
|
|
27
|
+
/** Creates a GitHub check run, returning the API-assigned check run id. */
|
|
28
|
+
export function createCheckRun(input, exec) {
|
|
29
|
+
if (!isFullCommitSha(input.headSha)) {
|
|
30
|
+
throw new CheckRunError('headSha must be a full lowercase 40-character commit SHA.');
|
|
31
|
+
}
|
|
32
|
+
const body = {
|
|
33
|
+
name: input.name,
|
|
34
|
+
head_sha: input.headSha,
|
|
35
|
+
status: input.status,
|
|
36
|
+
output: { title: input.title, summary: input.summary },
|
|
37
|
+
};
|
|
38
|
+
if (input.status === 'completed')
|
|
39
|
+
body.conclusion = input.conclusion;
|
|
40
|
+
const raw = exec(['api', '--method', 'POST', `repos/${input.repository}/check-runs`, '--input', '-'], {
|
|
41
|
+
input: JSON.stringify(body),
|
|
42
|
+
});
|
|
43
|
+
return parseCheckRunId(raw);
|
|
44
|
+
}
|
|
45
|
+
/** Transitions an existing check run to `completed` with a final conclusion. */
|
|
46
|
+
export function completeCheckRun(input, exec) {
|
|
47
|
+
if (!isCheckRunId(input.checkRunId)) {
|
|
48
|
+
throw new CheckRunError('checkRunId must be a positive integer.');
|
|
49
|
+
}
|
|
50
|
+
const body = {
|
|
51
|
+
status: 'completed',
|
|
52
|
+
conclusion: input.conclusion,
|
|
53
|
+
output: { title: input.title, summary: input.summary },
|
|
54
|
+
};
|
|
55
|
+
exec([
|
|
56
|
+
'api',
|
|
57
|
+
'--method',
|
|
58
|
+
'PATCH',
|
|
59
|
+
`repos/${input.repository}/check-runs/${input.checkRunId}`,
|
|
60
|
+
'--input',
|
|
61
|
+
'-',
|
|
62
|
+
], { input: JSON.stringify(body) });
|
|
63
|
+
}
|
|
@@ -3,7 +3,7 @@ import { writePostedOutput, postReviewFromEnv } from './github.mjs';
|
|
|
3
3
|
export async function runPostReviewCli(env = process.env) {
|
|
4
4
|
try {
|
|
5
5
|
const result = await postReviewFromEnv(env);
|
|
6
|
-
writePostedOutput(result.posted, env.GITHUB_OUTPUT);
|
|
6
|
+
writePostedOutput(result.posted, result.commentCount, env.GITHUB_OUTPUT);
|
|
7
7
|
return 0;
|
|
8
8
|
}
|
|
9
9
|
catch (error) {
|
|
@@ -1,29 +1,13 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { type
|
|
2
|
+
import { type PostReviewResult } from './post.mts';
|
|
3
|
+
export { createGhPostReviewIo, postWithGh } from './pull-io.mts';
|
|
3
4
|
export type GhExec = (args: readonly string[], options?: {
|
|
4
5
|
input?: string;
|
|
5
6
|
env?: NodeJS.ProcessEnv;
|
|
6
7
|
}) => string;
|
|
7
8
|
export declare function createGhExec(exec?: typeof execFileSync): GhExec;
|
|
8
|
-
export declare function
|
|
9
|
-
export declare function writePostedOutput(posted: boolean, outputPath?: string | undefined): void;
|
|
10
|
-
export declare function createGhPostReviewIo(options: {
|
|
11
|
-
repository: string;
|
|
12
|
-
prNumber: string;
|
|
13
|
-
payloadPath: string;
|
|
14
|
-
payloadBytes: Buffer;
|
|
15
|
-
token: string;
|
|
16
|
-
exec: GhExec;
|
|
17
|
-
expectedHeadSha?: string;
|
|
18
|
-
expectedBaseSha?: string;
|
|
19
|
-
}): PostReviewIo;
|
|
9
|
+
export declare function writePostedOutput(posted: boolean, commentCount: number, outputPath?: string | undefined): void;
|
|
20
10
|
/** @deprecated Use postReviewWithTokenFromEnv or postClaudeReviewFromEnv explicitly. */
|
|
21
|
-
export declare function postReviewFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, claudeIo?: import("./claude-token.mts").ClaudeTokenIo): Promise<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export declare function postReviewWithTokenFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, token?: string | undefined): {
|
|
25
|
-
posted: boolean;
|
|
26
|
-
};
|
|
27
|
-
export declare function postClaudeReviewFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, claudeIo?: import("./claude-token.mts").ClaudeTokenIo): Promise<{
|
|
28
|
-
posted: boolean;
|
|
29
|
-
}>;
|
|
11
|
+
export declare function postReviewFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, claudeIo?: import("./claude-token.mts").ClaudeTokenIo): Promise<PostReviewResult>;
|
|
12
|
+
export declare function postReviewWithTokenFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, token?: string | undefined): PostReviewResult;
|
|
13
|
+
export declare function postClaudeReviewFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, claudeIo?: import("./claude-token.mts").ClaudeTokenIo): Promise<PostReviewResult>;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { appendFileSync
|
|
3
|
-
import { parseReviewFilesJson } from '../gha-review-payload/index.mjs';
|
|
2
|
+
import { appendFileSync } from 'node:fs';
|
|
4
3
|
import { ReviewPayloadError } from '../gha-review-payload/index.mjs';
|
|
4
|
+
import { readRegularReviewPayload } from '../gha-review-payload/index.mjs';
|
|
5
5
|
import { createActionsClaudeTokenIo, withClaudeAppToken } from './claude-token.mjs';
|
|
6
6
|
import { runPostReview } from './post.mjs';
|
|
7
|
+
import { createGhPostReviewIo } from './pull-io.mjs';
|
|
7
8
|
import { requireEnv, resolveReviewPostToken } from './token.mjs';
|
|
8
|
-
|
|
9
|
+
export { createGhPostReviewIo, postWithGh } from './pull-io.mjs';
|
|
9
10
|
export function createGhExec(exec = execFileSync) {
|
|
10
11
|
return (args, options) => exec('gh', [...args], {
|
|
11
12
|
encoding: 'utf8',
|
|
@@ -13,96 +14,10 @@ export function createGhExec(exec = execFileSync) {
|
|
|
13
14
|
env: options?.env ?? process.env,
|
|
14
15
|
}).trim();
|
|
15
16
|
}
|
|
16
|
-
export function
|
|
17
|
-
try {
|
|
18
|
-
exec(['api', '--method', 'POST', `repos/${repository}/pulls/${prNumber}/reviews`, '--input', '-'], {
|
|
19
|
-
input: JSON.stringify(payload),
|
|
20
|
-
env: { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token },
|
|
21
|
-
});
|
|
22
|
-
return { ok: true, status: 201, body: '' };
|
|
23
|
-
}
|
|
24
|
-
catch (error) {
|
|
25
|
-
// oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- isolate the process-error shape assertion at the catch boundary
|
|
26
|
-
const err = error;
|
|
27
|
-
const text = `${err.stdout ?? ''}${err.stderr ?? ''}${err.message ?? ''}`;
|
|
28
|
-
const status = Number(/HTTP\s+(\d{3})/u.exec(text)?.[1] ?? 0);
|
|
29
|
-
return { ok: false, status, body: text };
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
export function writePostedOutput(posted, outputPath = process.env.GITHUB_OUTPUT) {
|
|
17
|
+
export function writePostedOutput(posted, commentCount, outputPath = process.env.GITHUB_OUTPUT) {
|
|
33
18
|
if (!outputPath)
|
|
34
19
|
return;
|
|
35
|
-
appendFileSync(outputPath, `posted=${posted ? 'true' : 'false'}\n`);
|
|
36
|
-
}
|
|
37
|
-
function readPullState(repository, prNumber, exec) {
|
|
38
|
-
let lastError;
|
|
39
|
-
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
40
|
-
try {
|
|
41
|
-
return exec([
|
|
42
|
-
'api',
|
|
43
|
-
`repos/${repository}/pulls/${prNumber}`,
|
|
44
|
-
'--jq',
|
|
45
|
-
'[.head.sha, .base.sha, .draft, .state] | @tsv',
|
|
46
|
-
]).split('\t');
|
|
47
|
-
}
|
|
48
|
-
catch (error) {
|
|
49
|
-
lastError = error;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
throw lastError;
|
|
53
|
-
}
|
|
54
|
-
export function createGhPostReviewIo(options) {
|
|
55
|
-
const { repository, prNumber, payloadBytes, token, exec, expectedHeadSha, expectedBaseSha } = options;
|
|
56
|
-
return {
|
|
57
|
-
readFile() {
|
|
58
|
-
return payloadBytes;
|
|
59
|
-
},
|
|
60
|
-
removeFile(path) {
|
|
61
|
-
rmSync(path, { force: true });
|
|
62
|
-
},
|
|
63
|
-
getHeadSha() {
|
|
64
|
-
if (!/^[1-9][0-9]*$/u.test(prNumber)) {
|
|
65
|
-
throw new ReviewPayloadError('PR_NUMBER must be a positive integer.');
|
|
66
|
-
}
|
|
67
|
-
if (Boolean(expectedHeadSha) !== Boolean(expectedBaseSha)) {
|
|
68
|
-
throw new ReviewPayloadError('EXPECTED_HEAD_SHA and EXPECTED_BASE_SHA must be provided together.');
|
|
69
|
-
}
|
|
70
|
-
if (expectedHeadSha && !/^[0-9a-f]{40}$/u.test(expectedHeadSha)) {
|
|
71
|
-
throw new ReviewPayloadError('EXPECTED_HEAD_SHA must be a full lowercase commit SHA.');
|
|
72
|
-
}
|
|
73
|
-
if (expectedBaseSha && !/^[0-9a-f]{40}$/u.test(expectedBaseSha)) {
|
|
74
|
-
throw new ReviewPayloadError('EXPECTED_BASE_SHA must be a full lowercase commit SHA.');
|
|
75
|
-
}
|
|
76
|
-
const state = readPullState(repository, prNumber, exec);
|
|
77
|
-
const [headSha = '', baseSha = '', draft = '', pullState = ''] = state;
|
|
78
|
-
if (!/^[0-9a-f]{40}$/u.test(headSha)) {
|
|
79
|
-
throw new ReviewPayloadError(`Could not resolve PR head SHA (got "${headSha}").`);
|
|
80
|
-
}
|
|
81
|
-
if (!/^[0-9a-f]{40}$/u.test(baseSha)) {
|
|
82
|
-
throw new ReviewPayloadError(`Could not resolve PR base SHA (got "${baseSha}").`);
|
|
83
|
-
}
|
|
84
|
-
if (draft !== 'false') {
|
|
85
|
-
throw new ReviewPayloadError('Pull request became a draft before posting the review.');
|
|
86
|
-
}
|
|
87
|
-
if (pullState !== 'open') {
|
|
88
|
-
throw new ReviewPayloadError('Pull request closed before posting the review.');
|
|
89
|
-
}
|
|
90
|
-
if (expectedHeadSha && headSha !== expectedHeadSha) {
|
|
91
|
-
throw new ReviewPayloadError('PR head changed before posting the selected review.');
|
|
92
|
-
}
|
|
93
|
-
if (expectedBaseSha && baseSha !== expectedBaseSha) {
|
|
94
|
-
throw new ReviewPayloadError('PR base changed before posting the selected review.');
|
|
95
|
-
}
|
|
96
|
-
// Preserve the orchestrator-selected revision as the review commit_id after equality checks.
|
|
97
|
-
return expectedHeadSha || headSha;
|
|
98
|
-
},
|
|
99
|
-
listPullFiles() {
|
|
100
|
-
return parseReviewFilesJson(exec(['api', '--paginate', `repos/${repository}/pulls/${prNumber}/files?per_page=100`]));
|
|
101
|
-
},
|
|
102
|
-
postReview(payload) {
|
|
103
|
-
return postWithGh(repository, prNumber, payload, token, exec);
|
|
104
|
-
},
|
|
105
|
-
};
|
|
20
|
+
appendFileSync(outputPath, `posted=${posted ? 'true' : 'false'}\ncomment_count=${commentCount}\n`);
|
|
106
21
|
}
|
|
107
22
|
/** @deprecated Use postReviewWithTokenFromEnv or postClaudeReviewFromEnv explicitly. */
|
|
108
23
|
export async function postReviewFromEnv(env = process.env, exec = createGhExec(), claudeIo = createActionsClaudeTokenIo(env)) {
|
|
@@ -116,6 +31,7 @@ function createPostWithToken(env, exec) {
|
|
|
116
31
|
const prNumber = requireEnv('PR_NUMBER', env);
|
|
117
32
|
const payloadPath = requireEnv('CODE_REVIEW_PAYLOAD_PATH', env);
|
|
118
33
|
const payloadBytes = readRegularReviewPayload(payloadPath, 'required');
|
|
34
|
+
const providerName = env.PROVIDER_NAME || undefined;
|
|
119
35
|
return (token) => runPostReview(payloadPath, createGhPostReviewIo({
|
|
120
36
|
repository,
|
|
121
37
|
prNumber,
|
|
@@ -125,7 +41,7 @@ function createPostWithToken(env, exec) {
|
|
|
125
41
|
exec,
|
|
126
42
|
expectedHeadSha: env.EXPECTED_HEAD_SHA ?? '',
|
|
127
43
|
expectedBaseSha: env.EXPECTED_BASE_SHA ?? '',
|
|
128
|
-
}));
|
|
44
|
+
}), providerName);
|
|
129
45
|
}
|
|
130
46
|
function withTokenEnv(exec, env, token) {
|
|
131
47
|
const tokenEnv = { ...env, GH_TOKEN: token, GITHUB_TOKEN: token };
|
|
@@ -13,6 +13,12 @@ export type PostReviewIo = {
|
|
|
13
13
|
listPullFiles(): PullFile[];
|
|
14
14
|
postReview(payload: SanitizedReview): PostResult;
|
|
15
15
|
};
|
|
16
|
-
export
|
|
16
|
+
export type PostReviewResult = {
|
|
17
17
|
posted: boolean;
|
|
18
|
+
commentCount: number;
|
|
18
19
|
};
|
|
20
|
+
/**
|
|
21
|
+
* Posts a validated review, optionally prefixing the body with a provider attribution line.
|
|
22
|
+
* The attribution is cosmetic (final posted text only); it never affects staged payload bytes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runPostReview(payloadPath: string, io: PostReviewIo, providerName?: string): PostReviewResult;
|
|
@@ -6,7 +6,11 @@ MAX_REVIEW_COMMENTS as MAX_COMMENTS,
|
|
|
6
6
|
MAX_REVIEW_PAYLOAD_BYTES as MAX_PAYLOAD_BYTES,
|
|
7
7
|
// oxlint-disable-next-line no-mistakes/ts-no-export-renaming -- preserve the package's established public compatibility name
|
|
8
8
|
ReviewPayloadError as PostReviewError, } from '../gha-review-payload/index.mjs';
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Posts a validated review, optionally prefixing the body with a provider attribution line.
|
|
11
|
+
* The attribution is cosmetic (final posted text only); it never affects staged payload bytes.
|
|
12
|
+
*/
|
|
13
|
+
export function runPostReview(payloadPath, io, providerName) {
|
|
10
14
|
try {
|
|
11
15
|
const selectedHeadSha = io.getHeadSha();
|
|
12
16
|
let review = parseReviewPayload(io.readFile(payloadPath), selectedHeadSha);
|
|
@@ -20,9 +24,13 @@ export function runPostReview(payloadPath, io) {
|
|
|
20
24
|
if (io.getHeadSha() !== selectedHeadSha) {
|
|
21
25
|
throw new ReviewPayloadError('PR head changed while preparing the selected review.');
|
|
22
26
|
}
|
|
23
|
-
const
|
|
27
|
+
const commentCount = review.comments.length;
|
|
28
|
+
const attributedReview = providerName
|
|
29
|
+
? { ...review, body: `**${providerName} review**\n\n${review.body}` }
|
|
30
|
+
: review;
|
|
31
|
+
const first = io.postReview(attributedReview);
|
|
24
32
|
if (first.ok)
|
|
25
|
-
return { posted: true };
|
|
33
|
+
return { posted: true, commentCount };
|
|
26
34
|
throw new ReviewPayloadError(`GitHub review POST failed (HTTP ${first.status}).`);
|
|
27
35
|
}
|
|
28
36
|
finally {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { GhExec } from './github.mts';
|
|
2
|
+
import type { PostResult, PostReviewIo, SanitizedReview } from './post.mts';
|
|
3
|
+
export declare function postWithGh(repository: string, prNumber: string, payload: SanitizedReview, token: string, exec: GhExec): PostResult;
|
|
4
|
+
export declare function createGhPostReviewIo(options: {
|
|
5
|
+
repository: string;
|
|
6
|
+
prNumber: string;
|
|
7
|
+
payloadPath: string;
|
|
8
|
+
payloadBytes: Buffer;
|
|
9
|
+
token: string;
|
|
10
|
+
exec: GhExec;
|
|
11
|
+
expectedHeadSha?: string;
|
|
12
|
+
expectedBaseSha?: string;
|
|
13
|
+
}): PostReviewIo;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { parseReviewFilesJson, ReviewPayloadError } from '../gha-review-payload/index.mjs';
|
|
3
|
+
export function postWithGh(repository, prNumber, payload, token, exec) {
|
|
4
|
+
try {
|
|
5
|
+
exec(['api', '--method', 'POST', `repos/${repository}/pulls/${prNumber}/reviews`, '--input', '-'], {
|
|
6
|
+
input: JSON.stringify(payload),
|
|
7
|
+
env: { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token },
|
|
8
|
+
});
|
|
9
|
+
return { ok: true, status: 201, body: '' };
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
// oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- isolate the process-error shape assertion at the catch boundary
|
|
13
|
+
const err = error;
|
|
14
|
+
const text = `${err.stdout ?? ''}${err.stderr ?? ''}${err.message ?? ''}`;
|
|
15
|
+
const status = Number(/HTTP\s+(\d{3})/u.exec(text)?.[1] ?? 0);
|
|
16
|
+
return { ok: false, status, body: text };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function readPullState(repository, prNumber, exec) {
|
|
20
|
+
let lastError;
|
|
21
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
22
|
+
try {
|
|
23
|
+
return exec([
|
|
24
|
+
'api',
|
|
25
|
+
`repos/${repository}/pulls/${prNumber}`,
|
|
26
|
+
'--jq',
|
|
27
|
+
'[.head.sha, .base.sha, .draft, .state] | @tsv',
|
|
28
|
+
]).split('\t');
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
lastError = error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw lastError;
|
|
35
|
+
}
|
|
36
|
+
export function createGhPostReviewIo(options) {
|
|
37
|
+
const { repository, prNumber, payloadBytes, token, exec, expectedHeadSha, expectedBaseSha } = options;
|
|
38
|
+
return {
|
|
39
|
+
readFile() {
|
|
40
|
+
return payloadBytes;
|
|
41
|
+
},
|
|
42
|
+
removeFile(path) {
|
|
43
|
+
rmSync(path, { force: true });
|
|
44
|
+
},
|
|
45
|
+
getHeadSha() {
|
|
46
|
+
if (!/^[1-9][0-9]*$/u.test(prNumber)) {
|
|
47
|
+
throw new ReviewPayloadError('PR_NUMBER must be a positive integer.');
|
|
48
|
+
}
|
|
49
|
+
if (Boolean(expectedHeadSha) !== Boolean(expectedBaseSha)) {
|
|
50
|
+
throw new ReviewPayloadError('EXPECTED_HEAD_SHA and EXPECTED_BASE_SHA must be provided together.');
|
|
51
|
+
}
|
|
52
|
+
if (expectedHeadSha && !/^[0-9a-f]{40}$/u.test(expectedHeadSha)) {
|
|
53
|
+
throw new ReviewPayloadError('EXPECTED_HEAD_SHA must be a full lowercase commit SHA.');
|
|
54
|
+
}
|
|
55
|
+
if (expectedBaseSha && !/^[0-9a-f]{40}$/u.test(expectedBaseSha)) {
|
|
56
|
+
throw new ReviewPayloadError('EXPECTED_BASE_SHA must be a full lowercase commit SHA.');
|
|
57
|
+
}
|
|
58
|
+
const state = readPullState(repository, prNumber, exec);
|
|
59
|
+
const [headSha = '', baseSha = '', draft = '', pullState = ''] = state;
|
|
60
|
+
if (!/^[0-9a-f]{40}$/u.test(headSha)) {
|
|
61
|
+
throw new ReviewPayloadError(`Could not resolve PR head SHA (got "${headSha}").`);
|
|
62
|
+
}
|
|
63
|
+
if (!/^[0-9a-f]{40}$/u.test(baseSha)) {
|
|
64
|
+
throw new ReviewPayloadError(`Could not resolve PR base SHA (got "${baseSha}").`);
|
|
65
|
+
}
|
|
66
|
+
if (draft !== 'false') {
|
|
67
|
+
throw new ReviewPayloadError('Pull request became a draft before posting the review.');
|
|
68
|
+
}
|
|
69
|
+
if (pullState !== 'open') {
|
|
70
|
+
throw new ReviewPayloadError('Pull request closed before posting the review.');
|
|
71
|
+
}
|
|
72
|
+
if (expectedHeadSha && headSha !== expectedHeadSha) {
|
|
73
|
+
throw new ReviewPayloadError('PR head changed before posting the selected review.');
|
|
74
|
+
}
|
|
75
|
+
if (expectedBaseSha && baseSha !== expectedBaseSha) {
|
|
76
|
+
throw new ReviewPayloadError('PR base changed before posting the selected review.');
|
|
77
|
+
}
|
|
78
|
+
// Preserve the orchestrator-selected revision as the review commit_id after equality checks.
|
|
79
|
+
return expectedHeadSha || headSha;
|
|
80
|
+
},
|
|
81
|
+
listPullFiles() {
|
|
82
|
+
return parseReviewFilesJson(exec(['api', '--paginate', `repos/${repository}/pulls/${prNumber}/files?per_page=100`]));
|
|
83
|
+
},
|
|
84
|
+
postReview(payload) {
|
|
85
|
+
return postWithGh(repository, prNumber, payload, token, exec);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
package/dist/index.d.mts
CHANGED
|
@@ -25,7 +25,8 @@ export { requireUpToDate } from './require-up-to-date/index.mts';
|
|
|
25
25
|
export type { GitExecutor, RequireUpToDateOptions } from './require-up-to-date/index.mts';
|
|
26
26
|
export { gitleaksDirectoryScanArguments, runGitleaksDirectoryScan, } from './gitleaks-directory-scan/index.mts';
|
|
27
27
|
export type { DirectoryScanExecutor, GitleaksDirectoryScanOptions, } from './gitleaks-directory-scan/index.mts';
|
|
28
|
-
export { astGrepExamplesArguments, runAstGrepExamples } from './ast-grep-examples/index.mts';
|
|
28
|
+
export { astGrepExamplesArguments, compareAstGrepCompanions, runAstGrepExamples, } from './ast-grep-examples/index.mts';
|
|
29
|
+
export type { AstGrepCompanionContext, AstGrepCompanionDifference, AstGrepCompanionParityOptions, } from './ast-grep-examples/index.mts';
|
|
29
30
|
export type { AstGrepExamplesExecutor, AstGrepExamplesOptions } from './ast-grep-examples/index.mts';
|
|
30
31
|
export { astGrepPackPaths, astGrepPackPathsFrom } from './ast-grep-pack/index.mts';
|
|
31
32
|
export type { AstGrepPackPaths } from './ast-grep-pack/index.mts';
|
package/dist/index.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export { auditCiJobRuntime, parseWorkflowNameMatch } from './gha-runtime-audit/i
|
|
|
13
13
|
export { checkGhaWorkspacePolicy } from './gha-workspace-policy/index.mjs';
|
|
14
14
|
export { requireUpToDate } from './require-up-to-date/index.mjs';
|
|
15
15
|
export { gitleaksDirectoryScanArguments, runGitleaksDirectoryScan, } from './gitleaks-directory-scan/index.mjs';
|
|
16
|
-
export { astGrepExamplesArguments, runAstGrepExamples } from './ast-grep-examples/index.mjs';
|
|
16
|
+
export { astGrepExamplesArguments, compareAstGrepCompanions, runAstGrepExamples, } from './ast-grep-examples/index.mjs';
|
|
17
17
|
export { astGrepPackPaths, astGrepPackPathsFrom } from './ast-grep-pack/index.mjs';
|
|
18
18
|
export { createVitestBlobManifest, createVitestReportAttempt, inspectVitestBlobBundle, parseVitestBlobManifest, parseVitestReportAttempt, readVitestReportAttempts, serializeVitestBlobManifest, serializeVitestReportAttempt, VITEST_BLOB_MANIFEST_FILENAME, VITEST_BLOB_MANIFEST_VERSION, VITEST_REPORT_ATTEMPT_PREFIX, VITEST_REPORT_ATTEMPT_VERSION, vitestBlobBundlePaths, writeVitestBlobManifest, writeVitestReportAttempt, } from './vitest-blob-manifest/index.mjs';
|
|
19
19
|
export { prepareVitestReports } from './vitest-blob-manifest/reports.mjs';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { canonicalRepoPath, isInScope } from './paths.mjs';
|
|
1
|
+
import { assertWorkflowCommandData, canonicalRepoPath, isInScope } from './paths.mjs';
|
|
2
|
+
import { SCC_COMPLEXITY_LIMIT } from './constants.mjs';
|
|
2
3
|
export const SCC_COMPLEXITY_BASELINE_VERSION = 1;
|
|
3
4
|
export function parseSccComplexityBaseline(json) {
|
|
4
5
|
const parsed = JSON.parse(json);
|
|
@@ -41,7 +42,7 @@ export function validateSccComplexityBaseline(baseline, scopes, trackedFileSet,
|
|
|
41
42
|
throw new Error(`baseline entry ${key} is out of scope`);
|
|
42
43
|
throw new Error(`baseline entry ${key} is stale`);
|
|
43
44
|
}
|
|
44
|
-
if (value.complexity <= (scope.limit ??
|
|
45
|
+
if (value.complexity <= (scope.limit ?? SCC_COMPLEXITY_LIMIT))
|
|
45
46
|
throw new Error(`baseline entry ${key} is stale`);
|
|
46
47
|
}
|
|
47
48
|
}
|
|
@@ -76,6 +77,8 @@ function parseEntry(value, index) {
|
|
|
76
77
|
throw new Error(`baseline entry ${index} has an invalid scope`);
|
|
77
78
|
if (typeof value.file !== 'string' || value.file.length === 0)
|
|
78
79
|
throw new Error(`baseline entry ${index} has an invalid file`);
|
|
80
|
+
assertWorkflowCommandData(value.scope, `baseline entry ${index}`);
|
|
81
|
+
assertWorkflowCommandData(value.file, `baseline entry ${index}`);
|
|
79
82
|
if (typeof value.complexity !== 'number' ||
|
|
80
83
|
!Number.isSafeInteger(value.complexity) ||
|
|
81
84
|
value.complexity < 0)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SCC_COMPLEXITY_LIMIT = 50;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SCC_COMPLEXITY_LIMIT = 50;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { SharedContext } from '../shared-context/index.mts';
|
|
2
2
|
import type { SccComplexityOptions, SccComplexityScope, SccComplexityViolation } from './types.mts';
|
|
3
3
|
export { parseSccComplexityBaseline, SCC_COMPLEXITY_BASELINE_VERSION } from './baseline.mts';
|
|
4
|
+
export { SCC_COMPLEXITY_LIMIT } from './constants.mts';
|
|
4
5
|
export type { SccComplexityBaseline, SccComplexityBaselineEntry, SccComplexityOptions, SccComplexityScope, SccComplexityViolation, } from './types.mts';
|
|
5
|
-
export declare const SCC_COMPLEXITY_LIMIT = 50;
|
|
6
6
|
type RunScc = (outputPath: string, scope?: SccComplexityScope) => Promise<string>;
|
|
7
7
|
export declare function buildSccArgs(options?: SccComplexityOptions): string[];
|
|
8
8
|
export declare function checkSccComplexity(ctx: SharedContext, options?: SccComplexityOptions, runScc?: RunScc): Promise<{
|
|
@@ -4,11 +4,14 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { baselineEntryByScopeAndFile, normalizeSccComplexityBaseline, validateSccComplexityBaseline, } from './baseline.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { SCC_COMPLEXITY_LIMIT } from './constants.mjs';
|
|
8
|
+
import { canonicalRepoPath } from './paths.mjs';
|
|
8
9
|
import { parseSccComplexityValues } from './parser.mjs';
|
|
10
|
+
import { normalizeScopes, scopesFor } from './scope-config.mjs';
|
|
11
|
+
import { escapeWorkflowCommandMessage, escapeWorkflowCommandProperty } from './workflow-command.mjs';
|
|
9
12
|
const execFileAsync = promisify(execFile);
|
|
10
13
|
export { parseSccComplexityBaseline, SCC_COMPLEXITY_BASELINE_VERSION } from './baseline.mjs';
|
|
11
|
-
export
|
|
14
|
+
export { SCC_COMPLEXITY_LIMIT } from './constants.mjs';
|
|
12
15
|
const DEFAULT_INCLUDE_EXT = 'js,mts,jsx,ts,tsx';
|
|
13
16
|
const DEFAULT_EXCLUDE_DIR = '.git,fixtures,__tests__,test-helpers';
|
|
14
17
|
const DEFAULT_NOT_MATCH = String.raw `\.(test|spec)\.`;
|
|
@@ -31,10 +34,14 @@ export function buildSccArgs(options = {}) {
|
|
|
31
34
|
}
|
|
32
35
|
export async function checkSccComplexity(ctx, options = {}, runScc) {
|
|
33
36
|
if (!ctx.isInsideGitRepo)
|
|
34
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
errors: [
|
|
39
|
+
`::error::${escapeWorkflowCommandMessage(ctx.repoRoot)} is not inside a git repository`,
|
|
40
|
+
],
|
|
41
|
+
};
|
|
35
42
|
const dir = await mkdtemp(join(tmpdir(), options.tmpdirPrefix ?? DEFAULT_TMPDIR_PREFIX));
|
|
36
43
|
try {
|
|
37
|
-
const scopes = normalizeScopes(scopesFor(options), ctx.repoRoot);
|
|
44
|
+
const scopes = await normalizeScopes(scopesFor(options), ctx.repoRoot, options.limit);
|
|
38
45
|
const trackedFileSet = new Set([...ctx.trackedFileSet].map((file) => canonicalRepoPath(ctx.repoRoot, file)));
|
|
39
46
|
const baseline = options.baseline && normalizeSccComplexityBaseline(options.baseline, ctx.repoRoot);
|
|
40
47
|
const results = await runScopes(ctx, options, scopes, dir, trackedFileSet, runScc);
|
|
@@ -44,7 +51,7 @@ export async function checkSccComplexity(ctx, options = {}, runScc) {
|
|
|
44
51
|
}
|
|
45
52
|
catch (error) {
|
|
46
53
|
const message = error instanceof Error ? error.message : String(error);
|
|
47
|
-
return { errors: [`::error::scc-complexity failed: ${message}`] };
|
|
54
|
+
return { errors: [`::error::scc-complexity failed: ${escapeWorkflowCommandMessage(message)}`] };
|
|
48
55
|
}
|
|
49
56
|
finally {
|
|
50
57
|
await rm(dir, { force: true, recursive: true });
|
|
@@ -55,41 +62,16 @@ export function parseSccComplexityViolations(json, trackedFileSet, limit = SCC_C
|
|
|
55
62
|
.filter((value) => value.complexity > limit)
|
|
56
63
|
.toSorted(compare);
|
|
57
64
|
}
|
|
58
|
-
function scopesFor(options) {
|
|
59
|
-
if (!options.scopes) {
|
|
60
|
-
if (options.baseline)
|
|
61
|
-
throw new Error('baseline requires named scopes');
|
|
62
|
-
return [{ includePaths: [], name: '', ...options }];
|
|
63
|
-
}
|
|
64
|
-
if (options.scopes.length === 0)
|
|
65
|
-
throw new Error('scopes must not be empty');
|
|
66
|
-
const names = new Set();
|
|
67
|
-
for (const scope of options.scopes) {
|
|
68
|
-
if (!scope.name || names.has(scope.name))
|
|
69
|
-
throw new Error(`scope name ${scope.name || '(empty)'} is invalid`);
|
|
70
|
-
if (scope.includePaths.length === 0 || scope.includePaths.some((path) => !path))
|
|
71
|
-
throw new Error(`scope ${scope.name} must include at least one path`);
|
|
72
|
-
names.add(scope.name);
|
|
73
|
-
}
|
|
74
|
-
return options.scopes;
|
|
75
|
-
}
|
|
76
|
-
function normalizeScopes(scopes, repoRoot) {
|
|
77
|
-
return scopes.map((scope) => {
|
|
78
|
-
const includePaths = scope.includePaths.map((path) => canonicalRepoPath(repoRoot, path));
|
|
79
|
-
if (includePaths.some((path) => !isInsideRepo(path)))
|
|
80
|
-
throw new Error(`scope ${scope.name} includes a path outside the repository`);
|
|
81
|
-
return { ...scope, includePaths };
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
65
|
async function runScopes(ctx, options, scopes, dir, trackedFileSet, runScc) {
|
|
85
66
|
const results = [];
|
|
86
67
|
for (const [index, scope] of scopes.entries()) {
|
|
87
68
|
const outputPath = join(dir, `scope-${index}.json`);
|
|
88
69
|
const resolve = runScc ?? ((path) => runSccJson(ctx.repoRoot, path, options, scope));
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
70
|
+
const values = parseSccComplexityValues(await resolve(outputPath, scope), trackedFileSet, ctx.repoRoot);
|
|
71
|
+
const outside = values.find((value) => !isInScopeResult(scope, value.file));
|
|
72
|
+
if (outside)
|
|
73
|
+
throw new Error(`scope ${scope.name} received result outside its paths: ${outside.file}`);
|
|
74
|
+
results.push({ scope, values });
|
|
93
75
|
}
|
|
94
76
|
return results;
|
|
95
77
|
}
|
|
@@ -106,9 +88,15 @@ function formatViolations(results, baseline) {
|
|
|
106
88
|
const detail = entry
|
|
107
89
|
? `scc complexity ${value.complexity} exceeds baseline ceiling ${entry.complexity}`
|
|
108
90
|
: `scc complexity ${value.complexity} exceeds ${limit}; simplify or split this file`;
|
|
109
|
-
return [
|
|
91
|
+
return [
|
|
92
|
+
`::error file=${escapeWorkflowCommandProperty(value.file)}::${escapeWorkflowCommandMessage(`${prefix}${value.file}: ${detail}`)}`,
|
|
93
|
+
];
|
|
110
94
|
}));
|
|
111
95
|
}
|
|
96
|
+
function isInScopeResult(scope, file) {
|
|
97
|
+
return (scope.includePaths.length === 0 ||
|
|
98
|
+
scope.includePaths.some((path) => file === path || file.startsWith(`${path}/`) || path === '.'));
|
|
99
|
+
}
|
|
112
100
|
async function runSccJson(repoRoot, outputPath, options, scope) {
|
|
113
101
|
try {
|
|
114
102
|
await execFileAsync(options.command ?? 'scc', [...buildSccArgs({ ...options, ...scope }), ...scope.includePaths, '--output', outputPath], { cwd: repoRoot, maxBuffer: 1024 * 1024 });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export declare function canonicalRepoPath(repoRoot: string, path: string): string;
|
|
2
|
-
export declare function isInsideRepo(path: string): boolean;
|
|
3
2
|
export declare function isInScope(file: string, includePaths: readonly string[]): boolean;
|
|
3
|
+
export declare function canonicalScopePath(repoRoot: string, path: string): Promise<string>;
|
|
4
|
+
export declare function assertWorkflowCommandData(value: string, label: string): void;
|
|
@@ -1,11 +1,44 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve, win32 } from 'node:path';
|
|
2
3
|
export function canonicalRepoPath(repoRoot, path) {
|
|
3
4
|
const relativePath = relative(repoRoot, resolve(repoRoot, path.replaceAll('\\', '/'))).replaceAll('\\', '/');
|
|
4
5
|
return relativePath || '.';
|
|
5
6
|
}
|
|
6
|
-
|
|
7
|
+
function isInsideRepo(path) {
|
|
7
8
|
return path !== '..' && !path.startsWith('../');
|
|
8
9
|
}
|
|
9
10
|
export function isInScope(file, includePaths) {
|
|
10
11
|
return includePaths.some((includePath) => includePath === '.' || file === includePath || file.startsWith(`${includePath}/`));
|
|
11
12
|
}
|
|
13
|
+
export async function canonicalScopePath(repoRoot, path) {
|
|
14
|
+
if (isAbsolute(path) || win32.parse(path).root)
|
|
15
|
+
throw new Error('path is outside the repository');
|
|
16
|
+
const physicalRoot = await realpath(repoRoot);
|
|
17
|
+
const candidatePath = resolve(repoRoot, path.replaceAll('\\', '/'));
|
|
18
|
+
if (!isInsideRepo(canonicalRepoPath(repoRoot, candidatePath)))
|
|
19
|
+
throw new Error('path is outside the repository');
|
|
20
|
+
const physicalPath = await physicalPathFor(candidatePath, repoRoot, physicalRoot);
|
|
21
|
+
const canonicalPath = canonicalRepoPath(physicalRoot, physicalPath);
|
|
22
|
+
if (!isInsideRepo(canonicalPath))
|
|
23
|
+
throw new Error('path is outside the repository');
|
|
24
|
+
return canonicalPath;
|
|
25
|
+
}
|
|
26
|
+
export function assertWorkflowCommandData(value, label) {
|
|
27
|
+
if (/[\r\n]/.test(value))
|
|
28
|
+
throw new Error(`${label} contains a workflow command control character`);
|
|
29
|
+
}
|
|
30
|
+
async function physicalPathFor(path, repoRoot, physicalRoot) {
|
|
31
|
+
let existingPath = path;
|
|
32
|
+
while (existingPath !== repoRoot) {
|
|
33
|
+
try {
|
|
34
|
+
const physicalPath = await realpath(existingPath);
|
|
35
|
+
return resolve(physicalPath, relative(existingPath, path));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if ((await lstat(existingPath).catch(() => undefined))?.isSymbolicLink())
|
|
39
|
+
throw error;
|
|
40
|
+
existingPath = dirname(existingPath);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return resolve(physicalRoot, relative(repoRoot, path));
|
|
44
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { SccComplexityOptions, SccComplexityScope } from './types.mts';
|
|
2
|
+
export declare function scopesFor(options: SccComplexityOptions): readonly SccComplexityScope[];
|
|
3
|
+
export declare function normalizeScopes(scopes: readonly SccComplexityScope[], repoRoot: string, topLevelLimit: number | undefined): Promise<readonly SccComplexityScope[]>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { assertWorkflowCommandData, canonicalScopePath } from './paths.mjs';
|
|
2
|
+
export function scopesFor(options) {
|
|
3
|
+
if (!options.scopes) {
|
|
4
|
+
if (options.baseline)
|
|
5
|
+
throw new Error('baseline requires named scopes');
|
|
6
|
+
return [{ includePaths: [], name: '', ...options }];
|
|
7
|
+
}
|
|
8
|
+
if (options.scopes.length === 0)
|
|
9
|
+
throw new Error('scopes must not be empty');
|
|
10
|
+
const names = new Set();
|
|
11
|
+
for (const scope of options.scopes) {
|
|
12
|
+
if (!scope.name || names.has(scope.name))
|
|
13
|
+
throw new Error(`scope name ${scope.name || '(empty)'} is invalid`);
|
|
14
|
+
assertWorkflowCommandData(scope.name, 'scope name');
|
|
15
|
+
if (scope.includePaths.length === 0 || scope.includePaths.some((path) => !path))
|
|
16
|
+
throw new Error(`scope ${scope.name} must include at least one path`);
|
|
17
|
+
for (const path of scope.includePaths)
|
|
18
|
+
assertWorkflowCommandData(path, `scope ${scope.name} path`);
|
|
19
|
+
names.add(scope.name);
|
|
20
|
+
}
|
|
21
|
+
return options.scopes;
|
|
22
|
+
}
|
|
23
|
+
export async function normalizeScopes(scopes, repoRoot, topLevelLimit) {
|
|
24
|
+
return Promise.all(scopes.map(async (scope) => {
|
|
25
|
+
let includePaths;
|
|
26
|
+
try {
|
|
27
|
+
includePaths = await Promise.all(scope.includePaths.map((path) => canonicalScopePath(repoRoot, path)));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error(`scope ${scope.name} includes a path outside the repository`);
|
|
31
|
+
}
|
|
32
|
+
const limit = scope.limit ?? topLevelLimit;
|
|
33
|
+
return limit === undefined ? { ...scope, includePaths } : { ...scope, includePaths, limit };
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function escapeWorkflowCommandMessage(value) {
|
|
2
|
+
return value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
|
|
3
|
+
}
|
|
4
|
+
export function escapeWorkflowCommandProperty(value) {
|
|
5
|
+
return escapeWorkflowCommandMessage(value).replaceAll(':', '%3A').replaceAll(',', '%2C');
|
|
6
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -208,6 +208,11 @@
|
|
|
208
208
|
"import": "./dist/gha-claude-post-review/index.mjs",
|
|
209
209
|
"default": "./dist/gha-claude-post-review/index.mjs"
|
|
210
210
|
},
|
|
211
|
+
"./gha-check-run": {
|
|
212
|
+
"types": "./dist/gha-check-run/index.d.mts",
|
|
213
|
+
"import": "./dist/gha-check-run/index.mjs",
|
|
214
|
+
"default": "./dist/gha-check-run/index.mjs"
|
|
215
|
+
},
|
|
211
216
|
"./http-link-pagination": {
|
|
212
217
|
"types": "./dist/http-link-pagination/index.d.mts",
|
|
213
218
|
"import": "./dist/http-link-pagination/index.mjs",
|