vouchington-tooling 0.13.1 → 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.
Files changed (51) hide show
  1. package/README.md +26 -0
  2. package/dist/ast-grep-examples/companion-parity.d.mts +19 -0
  3. package/dist/ast-grep-examples/companion-parity.mjs +156 -0
  4. package/dist/ast-grep-examples/index.d.mts +2 -1
  5. package/dist/ast-grep-examples/index.mjs +1 -0
  6. package/dist/gha-check-run/cli.d.mts +2 -0
  7. package/dist/gha-check-run/cli.mjs +81 -0
  8. package/dist/gha-check-run/github.d.mts +30 -0
  9. package/dist/gha-check-run/github.mjs +63 -0
  10. package/dist/gha-check-run/index.d.mts +2 -0
  11. package/dist/gha-check-run/index.mjs +2 -0
  12. package/dist/gha-post-review/cli.mjs +1 -1
  13. package/dist/gha-post-review/github.d.mts +6 -22
  14. package/dist/gha-post-review/github.mjs +8 -92
  15. package/dist/gha-post-review/post.d.mts +7 -1
  16. package/dist/gha-post-review/post.mjs +11 -3
  17. package/dist/gha-post-review/pull-io.d.mts +13 -0
  18. package/dist/gha-post-review/pull-io.mjs +88 -0
  19. package/dist/index.d.mts +4 -3
  20. package/dist/index.mjs +2 -2
  21. package/dist/markdown/ast.d.mts +8 -0
  22. package/dist/markdown/ast.mjs +29 -0
  23. package/dist/markdown/index.d.mts +6 -0
  24. package/dist/markdown/index.mjs +5 -0
  25. package/dist/markdown/loose-pipe-rows.d.mts +2 -0
  26. package/dist/markdown/loose-pipe-rows.mjs +31 -0
  27. package/dist/markdown/sections.d.mts +2 -0
  28. package/dist/markdown/sections.mjs +25 -0
  29. package/dist/markdown/tables.d.mts +3 -0
  30. package/dist/markdown/tables.mjs +43 -0
  31. package/dist/markdown/text.d.mts +2 -0
  32. package/dist/markdown/text.mjs +22 -0
  33. package/dist/markdown/types.d.mts +35 -0
  34. package/dist/markdown/types.mjs +1 -0
  35. package/dist/scc-complexity/baseline.d.mts +6 -0
  36. package/dist/scc-complexity/baseline.mjs +90 -0
  37. package/dist/scc-complexity/constants.d.mts +1 -0
  38. package/dist/scc-complexity/constants.mjs +1 -0
  39. package/dist/scc-complexity/index.d.mts +6 -14
  40. package/dist/scc-complexity/index.mjs +64 -39
  41. package/dist/scc-complexity/parser.d.mts +2 -0
  42. package/dist/scc-complexity/parser.mjs +21 -0
  43. package/dist/scc-complexity/paths.d.mts +4 -0
  44. package/dist/scc-complexity/paths.mjs +44 -0
  45. package/dist/scc-complexity/scope-config.d.mts +3 -0
  46. package/dist/scc-complexity/scope-config.mjs +35 -0
  47. package/dist/scc-complexity/types.d.mts +32 -0
  48. package/dist/scc-complexity/types.mjs +1 -0
  49. package/dist/scc-complexity/workflow-command.d.mts +2 -0
  50. package/dist/scc-complexity/workflow-command.mjs +6 -0
  51. package/package.json +15 -1
@@ -3,8 +3,15 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
+ import { baselineEntryByScopeAndFile, normalizeSccComplexityBaseline, validateSccComplexityBaseline, } from './baseline.mjs';
7
+ import { SCC_COMPLEXITY_LIMIT } from './constants.mjs';
8
+ import { canonicalRepoPath } from './paths.mjs';
9
+ import { parseSccComplexityValues } from './parser.mjs';
10
+ import { normalizeScopes, scopesFor } from './scope-config.mjs';
11
+ import { escapeWorkflowCommandMessage, escapeWorkflowCommandProperty } from './workflow-command.mjs';
6
12
  const execFileAsync = promisify(execFile);
7
- export const SCC_COMPLEXITY_LIMIT = 50;
13
+ export { parseSccComplexityBaseline, SCC_COMPLEXITY_BASELINE_VERSION } from './baseline.mjs';
14
+ export { SCC_COMPLEXITY_LIMIT } from './constants.mjs';
8
15
  const DEFAULT_INCLUDE_EXT = 'js,mts,jsx,ts,tsx';
9
16
  const DEFAULT_EXCLUDE_DIR = '.git,fixtures,__tests__,test-helpers';
10
17
  const DEFAULT_NOT_MATCH = String.raw `\.(test|spec)\.`;
@@ -26,65 +33,83 @@ export function buildSccArgs(options = {}) {
26
33
  ];
27
34
  }
28
35
  export async function checkSccComplexity(ctx, options = {}, runScc) {
29
- if (!ctx.isInsideGitRepo) {
30
- return { errors: [`::error::${ctx.repoRoot} is not inside a git repository`] };
31
- }
36
+ if (!ctx.isInsideGitRepo)
37
+ return {
38
+ errors: [
39
+ `::error::${escapeWorkflowCommandMessage(ctx.repoRoot)} is not inside a git repository`,
40
+ ],
41
+ };
32
42
  const dir = await mkdtemp(join(tmpdir(), options.tmpdirPrefix ?? DEFAULT_TMPDIR_PREFIX));
33
- const outputPath = join(dir, 'scc.json');
34
- const limit = options.limit ?? SCC_COMPLEXITY_LIMIT;
35
43
  try {
36
- const resolve = runScc ?? ((path) => runSccJson(ctx.repoRoot, path, options));
37
- const json = await resolve(outputPath);
38
- const violations = parseSccComplexityViolations(json, ctx.trackedFileSet, limit);
39
- return { errors: violations.map((violation) => formatViolation(violation, limit)) };
44
+ const scopes = await normalizeScopes(scopesFor(options), ctx.repoRoot, options.limit);
45
+ const trackedFileSet = new Set([...ctx.trackedFileSet].map((file) => canonicalRepoPath(ctx.repoRoot, file)));
46
+ const baseline = options.baseline && normalizeSccComplexityBaseline(options.baseline, ctx.repoRoot);
47
+ const results = await runScopes(ctx, options, scopes, dir, trackedFileSet, runScc);
48
+ if (baseline)
49
+ validateSccComplexityBaseline(baseline, scopes, trackedFileSet, new Map(results.map(({ scope, values }) => [scope.name, values])));
50
+ return { errors: formatViolations(results, baseline) };
40
51
  }
41
52
  catch (error) {
42
53
  const message = error instanceof Error ? error.message : String(error);
43
- return { errors: [`::error::scc-complexity failed: ${message}`] };
54
+ return { errors: [`::error::scc-complexity failed: ${escapeWorkflowCommandMessage(message)}`] };
44
55
  }
45
56
  finally {
46
57
  await rm(dir, { force: true, recursive: true });
47
58
  }
48
59
  }
49
60
  export function parseSccComplexityViolations(json, trackedFileSet, limit = SCC_COMPLEXITY_LIMIT) {
50
- const parsed = JSON.parse(json);
51
- if (!Array.isArray(parsed))
52
- throw new Error('scc JSON output must be an array');
53
- const violations = [];
54
- for (const language of parsed) {
55
- if (!Array.isArray(language.Files))
56
- continue;
57
- for (const file of language.Files) {
58
- if (typeof file.Location !== 'string')
59
- continue;
60
- if (!trackedFileSet.has(file.Location))
61
- continue;
62
- if (typeof file.Complexity !== 'number')
63
- continue;
64
- if (file.Complexity <= limit)
65
- continue;
66
- violations.push({ file: file.Location, complexity: file.Complexity });
67
- }
61
+ return parseSccComplexityValues(json, trackedFileSet)
62
+ .filter((value) => value.complexity > limit)
63
+ .toSorted(compare);
64
+ }
65
+ async function runScopes(ctx, options, scopes, dir, trackedFileSet, runScc) {
66
+ const results = [];
67
+ for (const [index, scope] of scopes.entries()) {
68
+ const outputPath = join(dir, `scope-${index}.json`);
69
+ const resolve = runScc ?? ((path) => runSccJson(ctx.repoRoot, path, options, scope));
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 });
68
75
  }
69
- return violations.toSorted((a, b) => b.complexity - a.complexity || a.file.localeCompare(b.file));
76
+ return results;
77
+ }
78
+ function formatViolations(results, baseline) {
79
+ const entries = baselineEntryByScopeAndFile(baseline);
80
+ return results.flatMap(({ scope, values }) => values.flatMap((value) => {
81
+ const limit = scope.limit ?? SCC_COMPLEXITY_LIMIT;
82
+ if (value.complexity <= limit)
83
+ return [];
84
+ const entry = entries.get(scope.name)?.get(value.file);
85
+ if (entry && value.complexity <= entry.complexity)
86
+ return [];
87
+ const prefix = scope.name ? `[${scope.name}] ` : '';
88
+ const detail = entry
89
+ ? `scc complexity ${value.complexity} exceeds baseline ceiling ${entry.complexity}`
90
+ : `scc complexity ${value.complexity} exceeds ${limit}; simplify or split this file`;
91
+ return [
92
+ `::error file=${escapeWorkflowCommandProperty(value.file)}::${escapeWorkflowCommandMessage(`${prefix}${value.file}: ${detail}`)}`,
93
+ ];
94
+ }));
95
+ }
96
+ function isInScopeResult(scope, file) {
97
+ return (scope.includePaths.length === 0 ||
98
+ scope.includePaths.some((path) => file === path || file.startsWith(`${path}/`) || path === '.'));
70
99
  }
71
- async function runSccJson(repoRoot, outputPath, options) {
100
+ async function runSccJson(repoRoot, outputPath, options, scope) {
72
101
  try {
73
- await execFileAsync(options.command ?? 'scc', [...buildSccArgs(options), '--output', outputPath], {
74
- cwd: repoRoot,
75
- maxBuffer: 1024 * 1024,
76
- });
102
+ await execFileAsync(options.command ?? 'scc', [...buildSccArgs({ ...options, ...scope }), ...scope.includePaths, '--output', outputPath], { cwd: repoRoot, maxBuffer: 1024 * 1024 });
77
103
  }
78
104
  catch (error) {
79
- if (isNodeSystemError(error) && error.code === 'ENOENT') {
105
+ if (isNodeSystemError(error) && error.code === 'ENOENT')
80
106
  throw new Error('scc executable not found; install with mise install', { cause: error });
81
- }
82
107
  throw error;
83
108
  }
84
109
  return readFile(outputPath, 'utf8');
85
110
  }
86
- function formatViolation({ file, complexity }, limit) {
87
- return `::error file=${file}::${file}: scc complexity ${complexity} exceeds ${limit}; simplify or split this file`;
111
+ function compare(a, b) {
112
+ return b.complexity - a.complexity || a.file.localeCompare(b.file);
88
113
  }
89
114
  function isNodeSystemError(error) {
90
115
  return error instanceof Error && 'code' in error;
@@ -0,0 +1,2 @@
1
+ import type { SccComplexityValue } from './types.mts';
2
+ export declare function parseSccComplexityValues(json: string, trackedFileSet: ReadonlySet<string>, repoRoot?: string): SccComplexityValue[];
@@ -0,0 +1,21 @@
1
+ import { canonicalRepoPath } from './paths.mjs';
2
+ export function parseSccComplexityValues(json, trackedFileSet, repoRoot) {
3
+ const parsed = JSON.parse(json);
4
+ if (!Array.isArray(parsed))
5
+ throw new Error('scc JSON output must be an array');
6
+ const values = [];
7
+ for (const language of parsed) {
8
+ if (!Array.isArray(language.Files))
9
+ continue;
10
+ for (const file of language.Files) {
11
+ if (typeof file.Location !== 'string')
12
+ continue;
13
+ const location = repoRoot ? canonicalRepoPath(repoRoot, file.Location) : file.Location;
14
+ if (!trackedFileSet.has(location))
15
+ continue;
16
+ if (typeof file.Complexity === 'number')
17
+ values.push({ complexity: file.Complexity, file: location });
18
+ }
19
+ }
20
+ return values.toSorted((a, b) => b.complexity - a.complexity || a.file.localeCompare(b.file));
21
+ }
@@ -0,0 +1,4 @@
1
+ export declare function canonicalRepoPath(repoRoot: string, path: string): string;
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;
@@ -0,0 +1,44 @@
1
+ import { lstat, realpath } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, relative, resolve, win32 } from 'node:path';
3
+ export function canonicalRepoPath(repoRoot, path) {
4
+ const relativePath = relative(repoRoot, resolve(repoRoot, path.replaceAll('\\', '/'))).replaceAll('\\', '/');
5
+ return relativePath || '.';
6
+ }
7
+ function isInsideRepo(path) {
8
+ return path !== '..' && !path.startsWith('../');
9
+ }
10
+ export function isInScope(file, includePaths) {
11
+ return includePaths.some((includePath) => includePath === '.' || file === includePath || file.startsWith(`${includePath}/`));
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,32 @@
1
+ interface SccComplexitySettings {
2
+ limit?: number;
3
+ includeExt?: string;
4
+ excludeDir?: string;
5
+ notMatch?: string;
6
+ }
7
+ export interface SccComplexityScope extends SccComplexitySettings {
8
+ name: string;
9
+ includePaths: readonly string[];
10
+ }
11
+ export interface SccComplexityBaselineEntry {
12
+ scope: string;
13
+ file: string;
14
+ complexity: number;
15
+ }
16
+ export interface SccComplexityBaseline {
17
+ version: 1;
18
+ entries: readonly SccComplexityBaselineEntry[];
19
+ }
20
+ export interface SccComplexityOptions extends SccComplexitySettings {
21
+ tmpdirPrefix?: string;
22
+ command?: string;
23
+ scopes?: readonly SccComplexityScope[];
24
+ baseline?: SccComplexityBaseline;
25
+ }
26
+ export interface SccComplexityViolation {
27
+ file: string;
28
+ complexity: number;
29
+ }
30
+ export interface SccComplexityValue extends SccComplexityViolation {
31
+ }
32
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export declare function escapeWorkflowCommandMessage(value: string): string;
2
+ export declare function escapeWorkflowCommandProperty(value: string): string;
@@ -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.1",
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": {
@@ -178,6 +178,11 @@
178
178
  "import": "./dist/csv/index.mjs",
179
179
  "default": "./dist/csv/index.mjs"
180
180
  },
181
+ "./markdown": {
182
+ "types": "./dist/markdown/index.d.mts",
183
+ "import": "./dist/markdown/index.mjs",
184
+ "default": "./dist/markdown/index.mjs"
185
+ },
181
186
  "./http-body": {
182
187
  "types": "./dist/http-body/index.d.mts",
183
188
  "import": "./dist/http-body/index.mjs",
@@ -203,6 +208,11 @@
203
208
  "import": "./dist/gha-claude-post-review/index.mjs",
204
209
  "default": "./dist/gha-claude-post-review/index.mjs"
205
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
+ },
206
216
  "./http-link-pagination": {
207
217
  "types": "./dist/http-link-pagination/index.d.mts",
208
218
  "import": "./dist/http-link-pagination/index.mjs",
@@ -294,10 +304,14 @@
294
304
  "typecheck": "tsc --noEmit --project tsconfig.json"
295
305
  },
296
306
  "dependencies": {
307
+ "@types/mdast": "4.0.4",
308
+ "@types/unist": "3.0.3",
297
309
  "csv-parse": "7.0.2",
298
310
  "csv-stringify": "6.8.3",
299
311
  "dockerfile-ast": "0.7.1",
300
312
  "picomatch": "4.0.7",
313
+ "remark": "15.0.1",
314
+ "remark-gfm": "4.0.1",
301
315
  "smol-toml": "1.8.0",
302
316
  "yaml": "2.9.0"
303
317
  },