vouchington-tooling 0.13.1 → 0.13.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 CHANGED
@@ -186,6 +186,13 @@ import {
186
186
  import { buildOpenApiDocument, writeOpenApi } from 'vouchington-tooling/openapi-document'
187
187
  import { decide, deriveRetryAttempt } from 'vouchington-tooling/transient-retry'
188
188
  import { parseCsvRows, streamCsvRows } from 'vouchington-tooling/csv'
189
+ import {
190
+ extractLooseMarkdownTableRows,
191
+ extractMarkdownTables,
192
+ markdownSectionBetweenHeadings,
193
+ parseGfmMarkdown,
194
+ parseMarkdownTables,
195
+ } from 'vouchington-tooling/markdown'
189
196
  import { readResponseBody } from 'vouchington-tooling/http-body'
190
197
  import { runAstGrepRule } from 'vouchington-tooling/ast-grep-rule'
191
198
  import { parseReviewPayload, remapReviewComments } from 'vouchington-tooling/gha-review-payload'
@@ -228,6 +235,13 @@ import {
228
235
  } from 'vouchington-tooling/gh-api-shell-quoting'
229
236
  ```
230
237
 
238
+ `checkSccComplexity` keeps its single repository-wide scan when `scopes` is omitted. Consumers
239
+ that need separately ratcheted areas may provide named scopes with positional `includePaths`; SCC
240
+ runs once per scope and reports the scope in each diagnostic. Parse a consumer-owned JSON baseline
241
+ with `parseSccComplexityBaseline` and pass it as `baseline`. Baselines are versioned, record the
242
+ maximum permitted complexity for each `{ scope, file }`, suppress only values at or below that
243
+ ceiling, and reject malformed, duplicate, untracked, stale, or out-of-scope entries.
244
+
231
245
  `shellScriptViolations`/`workflowYamlViolations` flag a `gh api` call whose argument carries an
232
246
  unquoted `?` or `&`: an unquoted `&` silently backgrounds the command and truncates the query
233
247
  (the call still exits 0), and an unquoted `?` fails loudly under zsh glob-nomatch but passes
@@ -241,6 +255,13 @@ shell script or a workflow/action YAML file is left to the caller.
241
255
  code-point limit without altering it. Its result includes the UTF-8 byte count for diagnostics; that
242
256
  byte count is not a validation limit.
243
257
 
258
+ `vouchington-tooling/markdown` parses GFM into standard mdast/unist nodes, provides pre-order
259
+ walking and typed searches, normalizes node text, and extracts positioned tables or heading-bounded
260
+ source sections. `parseMarkdownTables` preserves its compact `{ cells, line }[][]` compatibility
261
+ shape, including short table delimiters. `extractLooseMarkdownTableRows` is intentionally literal
262
+ recovery for malformed pipe rows; callers supply table positions to exclude and retain ownership of
263
+ their policy interpretation.
264
+
244
265
  `agent-harness-config` merges classifier-auto and sandbox keys into Claude, Codex, Grok, and Cursor
245
266
  config files. See [docs/agent-harness-config.md](./docs/agent-harness-config.md). `--global` updates
246
267
  home-directory configs; `--repo` updates a checkout. It does not copy allowlists, hooks, or plugins.
package/dist/index.d.mts CHANGED
@@ -72,8 +72,8 @@ export { EPOCH_PRUNED_AT, normalizeDeployedLayer, pruneDeployedRuntimeDeps, rest
72
72
  export type { NormalizeDeployedLayerResult, PruneResult, RestoreWorkspacePackagesOptions, } from './pnpm-deploy/index.mts';
73
73
  export { parseDockerfilePrewarmStages, parseDockerfileRuntimeImages, } from './dockerfile-parse/index.mts';
74
74
  export type { DockerfilePrewarmStage, DockerfileRuntimeImage, ParseDockerfilePrewarmOptions, ParseDockerfileRuntimeImagesOptions, } from './dockerfile-parse/index.mts';
75
- export { buildSccArgs, checkSccComplexity, parseSccComplexityViolations, SCC_COMPLEXITY_LIMIT, } from './scc-complexity/index.mts';
76
- export type { SccComplexityOptions, SccComplexityViolation } from './scc-complexity/index.mts';
75
+ export { buildSccArgs, checkSccComplexity, parseSccComplexityBaseline, parseSccComplexityViolations, SCC_COMPLEXITY_BASELINE_VERSION, SCC_COMPLEXITY_LIMIT, } from './scc-complexity/index.mts';
76
+ export type { SccComplexityBaseline, SccComplexityBaselineEntry, SccComplexityOptions, SccComplexityScope, SccComplexityViolation, } from './scc-complexity/index.mts';
77
77
  export { assertWorkflowCommandDrift, parseCiLocalArgs, runCiLocal } from './ci-local/index.mts';
78
78
  export type { CiLocalCommand, CiLocalSpawn, CiLocalSpawnOptions, CiLocalSpawnResult, CiLocalTarget, RunCiLocalOptions, } from './ci-local/index.mts';
79
79
  export { GitHubRateLimitError, isRateLimited, isRetryableCancellationError, MAX_RATE_LIMIT_WAIT_MS, rateLimitDelay, reserveRateLimitDelay, } from './gha-rate-limit/index.mts';
package/dist/index.mjs CHANGED
@@ -39,7 +39,7 @@ export { nextPageCursorFromLinkHeader, nextPageUrlFromLinkHeader, validatePagina
39
39
  export { cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, mintPresignedControl, transportObjectKeys, } from './coverage-transport/index.mjs';
40
40
  export { EPOCH_PRUNED_AT, normalizeDeployedLayer, pruneDeployedRuntimeDeps, restoreDeployedWorkspacePackages, } from './pnpm-deploy/index.mjs';
41
41
  export { parseDockerfilePrewarmStages, parseDockerfileRuntimeImages, } from './dockerfile-parse/index.mjs';
42
- export { buildSccArgs, checkSccComplexity, parseSccComplexityViolations, SCC_COMPLEXITY_LIMIT, } from './scc-complexity/index.mjs';
42
+ export { buildSccArgs, checkSccComplexity, parseSccComplexityBaseline, parseSccComplexityViolations, SCC_COMPLEXITY_BASELINE_VERSION, SCC_COMPLEXITY_LIMIT, } from './scc-complexity/index.mjs';
43
43
  export { assertWorkflowCommandDrift, parseCiLocalArgs, runCiLocal } from './ci-local/index.mjs';
44
44
  export { GitHubRateLimitError, isRateLimited, isRetryableCancellationError, MAX_RATE_LIMIT_WAIT_MS, rateLimitDelay, reserveRateLimitDelay, } from './gha-rate-limit/index.mjs';
45
45
  export { CHECKPOINT_MARKER, isTrustedCheckpointComment, parseCheckpoint, renderCheckpoint, sortedCheckpointCandidates, validateCheckpoint, } from './gha-pr-checkpoint/index.mjs';
@@ -0,0 +1,8 @@
1
+ import type { Root } from 'mdast';
2
+ import type { MarkdownNode } from './types.mts';
3
+ export declare function parseGfmMarkdown(markdown: string): Root;
4
+ export declare function walkMarkdown(node: MarkdownNode, visitor: (node: MarkdownNode) => void): void;
5
+ export declare function findMarkdownNode<T extends MarkdownNode>(node: MarkdownNode, predicate: (node: MarkdownNode) => node is T): T | null;
6
+ export declare function findMarkdownNode(node: MarkdownNode, predicate: (node: MarkdownNode) => boolean): MarkdownNode | null;
7
+ export declare function findMarkdownNodes<T extends MarkdownNode>(node: MarkdownNode, predicate: (node: MarkdownNode) => node is T): T[];
8
+ export declare function findMarkdownNodes(node: MarkdownNode, predicate: (node: MarkdownNode) => boolean): MarkdownNode[];
@@ -0,0 +1,29 @@
1
+ import { remark } from 'remark';
2
+ import remarkGfm from 'remark-gfm';
3
+ const processor = remark().use(remarkGfm);
4
+ export function parseGfmMarkdown(markdown) {
5
+ return processor.parse(markdown);
6
+ }
7
+ export function walkMarkdown(node, visitor) {
8
+ visitor(node);
9
+ if (!('children' in node))
10
+ return;
11
+ for (const child of node.children)
12
+ walkMarkdown(child, visitor);
13
+ }
14
+ export function findMarkdownNode(node, predicate) {
15
+ let match = null;
16
+ walkMarkdown(node, (candidate) => {
17
+ if (!match && predicate(candidate))
18
+ match = candidate;
19
+ });
20
+ return match;
21
+ }
22
+ export function findMarkdownNodes(node, predicate) {
23
+ const matches = [];
24
+ walkMarkdown(node, (candidate) => {
25
+ if (predicate(candidate))
26
+ matches.push(candidate);
27
+ });
28
+ return matches;
29
+ }
@@ -0,0 +1,6 @@
1
+ export { findMarkdownNode, findMarkdownNodes, parseGfmMarkdown, walkMarkdown } from './ast.mts';
2
+ export { extractLooseMarkdownTableRows } from './loose-pipe-rows.mts';
3
+ export { markdownSectionBetweenHeadings } from './sections.mts';
4
+ export { extractMarkdownTables, parseMarkdownTables } from './tables.mts';
5
+ export { markdownNodeText } from './text.mts';
6
+ export type { LooseMarkdownTableRow, LooseMarkdownTableRowsOptions, MarkdownNode, MarkdownSection, MarkdownSectionOptions, MarkdownTableRow, MarkdownTextOptions, ParseMarkdownTablesOptions, PositionedMarkdownTable, } from './types.mts';
@@ -0,0 +1,5 @@
1
+ export { findMarkdownNode, findMarkdownNodes, parseGfmMarkdown, walkMarkdown } from './ast.mjs';
2
+ export { extractLooseMarkdownTableRows } from './loose-pipe-rows.mjs';
3
+ export { markdownSectionBetweenHeadings } from './sections.mjs';
4
+ export { extractMarkdownTables, parseMarkdownTables } from './tables.mjs';
5
+ export { markdownNodeText } from './text.mjs';
@@ -0,0 +1,2 @@
1
+ import type { LooseMarkdownTableRow, LooseMarkdownTableRowsOptions } from './types.mts';
2
+ export declare function extractLooseMarkdownTableRows(markdown: string, options?: LooseMarkdownTableRowsOptions): LooseMarkdownTableRow[];
@@ -0,0 +1,31 @@
1
+ export function extractLooseMarkdownTableRows(markdown, options = {}) {
2
+ const rows = [];
3
+ let offset = 0;
4
+ for (const [index, line] of markdown.split('\n').entries()) {
5
+ if (!isExcluded(offset, offset + line.length, options.excludePositions)) {
6
+ const cells = parseLoosePipeRow(line);
7
+ if (cells.length > 0)
8
+ rows.push({ cells, line: index + 1, offset });
9
+ }
10
+ offset += line.length + 1;
11
+ }
12
+ return rows;
13
+ }
14
+ function isExcluded(lineStart, lineEnd, positions) {
15
+ return (positions?.some((position) => {
16
+ const start = position.start.offset;
17
+ const end = position.end.offset;
18
+ return start !== undefined && end !== undefined && lineStart < end && lineEnd > start;
19
+ }) ?? false);
20
+ }
21
+ function parseLoosePipeRow(line) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed.startsWith('|'))
24
+ return [];
25
+ const row = trimmed.endsWith('|') ? trimmed.slice(1, -1) : trimmed.slice(1);
26
+ const cells = row
27
+ .split('|')
28
+ .map((cell) => cell.trim())
29
+ .filter(Boolean);
30
+ return cells.every((cell) => /^:?-{3,}:?$/u.test(cell)) ? [] : cells;
31
+ }
@@ -0,0 +1,2 @@
1
+ import type { MarkdownSection, MarkdownSectionOptions } from './types.mts';
2
+ export declare function markdownSectionBetweenHeadings(markdown: string, start: string, end: string, options?: MarkdownSectionOptions): MarkdownSection | null;
@@ -0,0 +1,25 @@
1
+ import { parseGfmMarkdown } from './ast.mjs';
2
+ import { markdownNodeText } from './text.mjs';
3
+ export function markdownSectionBetweenHeadings(markdown, start, end, options = {}) {
4
+ const children = parseGfmMarkdown(markdown).children;
5
+ const startIndex = children.findIndex((node) => isNamedHeading(node, start));
6
+ if (startIndex < 0)
7
+ return null;
8
+ const startHeading = children[startIndex];
9
+ const endIndex = children.findIndex((node, index) => index > startIndex &&
10
+ isNamedHeading(node, end) &&
11
+ (options.endBoundary === 'any' || node.depth <= startHeading.depth));
12
+ const endHeading = endIndex < 0 ? null : children[endIndex];
13
+ const startOffset = startHeading.position.end.offset;
14
+ const endOffset = endHeading ? endHeading.position.start.offset : markdown.length;
15
+ return {
16
+ content: markdown.slice(startOffset, endOffset),
17
+ endHeading,
18
+ endOffset,
19
+ startHeading,
20
+ startOffset,
21
+ };
22
+ }
23
+ function isNamedHeading(node, expected) {
24
+ return node.type === 'heading' && markdownNodeText(node, { whitespace: 'collapse' }) === expected;
25
+ }
@@ -0,0 +1,3 @@
1
+ import type { MarkdownNode, MarkdownTableRow, ParseMarkdownTablesOptions, PositionedMarkdownTable } from './types.mts';
2
+ export declare function extractMarkdownTables(root: MarkdownNode): PositionedMarkdownTable[];
3
+ export declare function parseMarkdownTables(markdown: string, options?: ParseMarkdownTablesOptions): MarkdownTableRow[][];
@@ -0,0 +1,43 @@
1
+ import { findMarkdownNodes, parseGfmMarkdown } from './ast.mjs';
2
+ import { markdownNodeText } from './text.mjs';
3
+ export function extractMarkdownTables(root) {
4
+ return findMarkdownNodes(root, (node) => node.type === 'table').map((table) => {
5
+ const result = {
6
+ rows: normalizeTableRows(table),
7
+ };
8
+ if (table.position)
9
+ result.position = table.position;
10
+ return result;
11
+ });
12
+ }
13
+ export function parseMarkdownTables(markdown, options = {}) {
14
+ const root = parseGfmMarkdown(normalizeTableDelimiterRows(markdown));
15
+ return findMarkdownNodes(root, (node) => node.type === 'table').map((table) => normalizeTableRows(table, {
16
+ inlineCode: options.preserveInlineCodeMarkers ? 'markers' : 'content',
17
+ issueReferences: 'brackets',
18
+ }));
19
+ }
20
+ function normalizeTableRows(table, textOptions = {}) {
21
+ return table.children.map((row) => ({
22
+ cells: row.children.map((cell) => markdownNodeText(cell, textOptions).trim()),
23
+ line: row.position?.start.line ?? 1,
24
+ }));
25
+ }
26
+ function normalizeTableDelimiterRows(markdown) {
27
+ return markdown
28
+ .split('\n')
29
+ .map((line) => {
30
+ const trimmed = line.trim();
31
+ if (!trimmed.startsWith('|'))
32
+ return line;
33
+ const cells = trimmed
34
+ .replace(/^\|/u, '')
35
+ .replace(/\|$/u, '')
36
+ .split('|')
37
+ .map((cell) => cell.trim());
38
+ if (!cells.every((cell) => /^:?-+:?$/u.test(cell)))
39
+ return line;
40
+ return `| ${cells.map((cell) => cell.replace(/-+/u, '---')).join(' | ')} |`;
41
+ })
42
+ .join('\n');
43
+ }
@@ -0,0 +1,2 @@
1
+ import type { MarkdownNode, MarkdownTextOptions } from './types.mts';
2
+ export declare function markdownNodeText(node: MarkdownNode, options?: MarkdownTextOptions): string;
@@ -0,0 +1,22 @@
1
+ export function markdownNodeText(node, options = {}) {
2
+ const text = textFor(node, options);
3
+ return options.whitespace === 'collapse' ? text.replace(/\s+/gu, ' ').trim() : text;
4
+ }
5
+ function textFor(node, options) {
6
+ if (node.type === 'break')
7
+ return ' ';
8
+ if ('value' in node && typeof node.value === 'string') {
9
+ if (node.type === 'inlineCode' && options.inlineCode === 'markers')
10
+ return `\`${node.value}\``;
11
+ return node.value;
12
+ }
13
+ if (!('children' in node))
14
+ return '';
15
+ const text = node.children.map((child) => textFor(child, options)).join('');
16
+ if (options.issueReferences === 'brackets' &&
17
+ (node.type === 'link' || node.type === 'linkReference') &&
18
+ /^#\d+$/u.test(text.trim())) {
19
+ return `[${text.trim()}]`;
20
+ }
21
+ return text;
22
+ }
@@ -0,0 +1,35 @@
1
+ import type { Heading, Root, RootContent } from 'mdast';
2
+ import type { Position } from 'unist';
3
+ export type MarkdownNode = Root | RootContent;
4
+ export type MarkdownTableRow = {
5
+ cells: string[];
6
+ line: number;
7
+ };
8
+ export type PositionedMarkdownTable = {
9
+ position?: Position;
10
+ rows: MarkdownTableRow[];
11
+ };
12
+ export type MarkdownTextOptions = {
13
+ inlineCode?: 'content' | 'markers';
14
+ issueReferences?: 'text' | 'brackets';
15
+ whitespace?: 'preserve' | 'collapse';
16
+ };
17
+ export type ParseMarkdownTablesOptions = {
18
+ preserveInlineCodeMarkers?: boolean;
19
+ };
20
+ export type MarkdownSectionOptions = {
21
+ endBoundary?: 'same-or-higher' | 'any';
22
+ };
23
+ export type LooseMarkdownTableRowsOptions = {
24
+ excludePositions?: readonly Position[];
25
+ };
26
+ export type MarkdownSection = {
27
+ content: string;
28
+ endHeading: Heading | null;
29
+ endOffset: number;
30
+ startHeading: Heading;
31
+ startOffset: number;
32
+ };
33
+ export type LooseMarkdownTableRow = MarkdownTableRow & {
34
+ offset: number;
35
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { SccComplexityBaseline, SccComplexityBaselineEntry, SccComplexityScope, SccComplexityValue } from './types.mts';
2
+ export declare const SCC_COMPLEXITY_BASELINE_VERSION: 1;
3
+ export declare function parseSccComplexityBaseline(json: string): SccComplexityBaseline;
4
+ export declare function validateSccComplexityBaseline(baseline: SccComplexityBaseline, scopes: readonly SccComplexityScope[], trackedFileSet: ReadonlySet<string>, valuesByScope: ReadonlyMap<string, readonly SccComplexityValue[]>): void;
5
+ export declare function normalizeSccComplexityBaseline(baseline: SccComplexityBaseline, repoRoot: string): SccComplexityBaseline;
6
+ export declare function baselineEntryByScopeAndFile(baseline: SccComplexityBaseline | undefined): ReadonlyMap<string, ReadonlyMap<string, SccComplexityBaselineEntry>>;
@@ -0,0 +1,87 @@
1
+ import { canonicalRepoPath, isInScope } from './paths.mjs';
2
+ export const SCC_COMPLEXITY_BASELINE_VERSION = 1;
3
+ export function parseSccComplexityBaseline(json) {
4
+ const parsed = JSON.parse(json);
5
+ if (!isRecord(parsed))
6
+ throw new Error('baseline must be an object');
7
+ if (parsed.version !== SCC_COMPLEXITY_BASELINE_VERSION)
8
+ throw new Error(`baseline version must be ${SCC_COMPLEXITY_BASELINE_VERSION}`);
9
+ if (!Array.isArray(parsed.entries))
10
+ throw new Error('baseline entries must be an array');
11
+ const entries = parsed.entries.map(parseEntry);
12
+ const seen = new Map();
13
+ for (const entry of entries) {
14
+ if (seen.get(entry.scope)?.has(entry.file))
15
+ throw new Error(`baseline entry ${entry.scope}:${entry.file} is duplicated`);
16
+ const files = seen.get(entry.scope) ?? new Set();
17
+ files.add(entry.file);
18
+ seen.set(entry.scope, files);
19
+ }
20
+ return { entries, version: SCC_COMPLEXITY_BASELINE_VERSION };
21
+ }
22
+ export function validateSccComplexityBaseline(baseline, scopes, trackedFileSet, valuesByScope) {
23
+ const scopeByName = new Map(scopes.map((scope) => [scope.name, scope]));
24
+ const allFiles = new Set();
25
+ for (const values of valuesByScope.values()) {
26
+ for (const value of values)
27
+ allFiles.add(value.file);
28
+ }
29
+ for (const entry of baseline.entries) {
30
+ const key = `${entry.scope}:${entry.file}`;
31
+ const scope = scopeByName.get(entry.scope);
32
+ if (!scope)
33
+ throw new Error(`baseline entry ${key} is out of scope`);
34
+ if (!trackedFileSet.has(entry.file))
35
+ throw new Error(`baseline entry ${key} is untracked`);
36
+ if (!isInScope(entry.file, scope.includePaths))
37
+ throw new Error(`baseline entry ${key} is out of scope`);
38
+ const value = valuesByScope.get(scope.name)?.find((item) => item.file === entry.file);
39
+ if (!value) {
40
+ if (allFiles.has(entry.file))
41
+ throw new Error(`baseline entry ${key} is out of scope`);
42
+ throw new Error(`baseline entry ${key} is stale`);
43
+ }
44
+ if (value.complexity <= (scope.limit ?? 50))
45
+ throw new Error(`baseline entry ${key} is stale`);
46
+ }
47
+ }
48
+ export function normalizeSccComplexityBaseline(baseline, repoRoot) {
49
+ const entries = baseline.entries.map((entry) => ({
50
+ ...entry,
51
+ file: canonicalRepoPath(repoRoot, entry.file),
52
+ }));
53
+ const seen = new Map();
54
+ for (const entry of entries) {
55
+ if (seen.get(entry.scope)?.has(entry.file))
56
+ throw new Error(`baseline entry ${entry.scope}:${entry.file} is duplicated`);
57
+ const files = seen.get(entry.scope) ?? new Set();
58
+ files.add(entry.file);
59
+ seen.set(entry.scope, files);
60
+ }
61
+ return { entries, version: baseline.version };
62
+ }
63
+ export function baselineEntryByScopeAndFile(baseline) {
64
+ const scopes = new Map();
65
+ for (const entry of baseline?.entries ?? []) {
66
+ const files = scopes.get(entry.scope) ?? new Map();
67
+ files.set(entry.file, entry);
68
+ scopes.set(entry.scope, files);
69
+ }
70
+ return scopes;
71
+ }
72
+ function parseEntry(value, index) {
73
+ if (!isRecord(value))
74
+ throw new Error(`baseline entry ${index} must be an object`);
75
+ if (typeof value.scope !== 'string' || value.scope.length === 0)
76
+ throw new Error(`baseline entry ${index} has an invalid scope`);
77
+ if (typeof value.file !== 'string' || value.file.length === 0)
78
+ throw new Error(`baseline entry ${index} has an invalid file`);
79
+ if (typeof value.complexity !== 'number' ||
80
+ !Number.isSafeInteger(value.complexity) ||
81
+ value.complexity < 0)
82
+ throw new Error(`baseline entry ${index} has an invalid complexity`);
83
+ return { complexity: value.complexity, file: value.file, scope: value.scope };
84
+ }
85
+ function isRecord(value) {
86
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
87
+ }
@@ -1,19 +1,11 @@
1
1
  import type { SharedContext } from '../shared-context/index.mts';
2
+ import type { SccComplexityOptions, SccComplexityScope, SccComplexityViolation } from './types.mts';
3
+ export { parseSccComplexityBaseline, SCC_COMPLEXITY_BASELINE_VERSION } from './baseline.mts';
4
+ export type { SccComplexityBaseline, SccComplexityBaselineEntry, SccComplexityOptions, SccComplexityScope, SccComplexityViolation, } from './types.mts';
2
5
  export declare const SCC_COMPLEXITY_LIMIT = 50;
3
- export interface SccComplexityOptions {
4
- limit?: number;
5
- includeExt?: string;
6
- excludeDir?: string;
7
- notMatch?: string;
8
- tmpdirPrefix?: string;
9
- command?: string;
10
- }
11
- export interface SccComplexityViolation {
12
- file: string;
13
- complexity: number;
14
- }
6
+ type RunScc = (outputPath: string, scope?: SccComplexityScope) => Promise<string>;
15
7
  export declare function buildSccArgs(options?: SccComplexityOptions): string[];
16
- export declare function checkSccComplexity(ctx: SharedContext, options?: SccComplexityOptions, runScc?: (outputPath: string) => Promise<string>): Promise<{
8
+ export declare function checkSccComplexity(ctx: SharedContext, options?: SccComplexityOptions, runScc?: RunScc): Promise<{
17
9
  errors: string[];
18
10
  }>;
19
11
  export declare function parseSccComplexityViolations(json: string, trackedFileSet: ReadonlySet<string>, limit?: number): SccComplexityViolation[];
@@ -3,7 +3,11 @@ 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 { canonicalRepoPath, isInsideRepo } from './paths.mjs';
8
+ import { parseSccComplexityValues } from './parser.mjs';
6
9
  const execFileAsync = promisify(execFile);
10
+ export { parseSccComplexityBaseline, SCC_COMPLEXITY_BASELINE_VERSION } from './baseline.mjs';
7
11
  export const SCC_COMPLEXITY_LIMIT = 50;
8
12
  const DEFAULT_INCLUDE_EXT = 'js,mts,jsx,ts,tsx';
9
13
  const DEFAULT_EXCLUDE_DIR = '.git,fixtures,__tests__,test-helpers';
@@ -26,17 +30,17 @@ export function buildSccArgs(options = {}) {
26
30
  ];
27
31
  }
28
32
  export async function checkSccComplexity(ctx, options = {}, runScc) {
29
- if (!ctx.isInsideGitRepo) {
33
+ if (!ctx.isInsideGitRepo)
30
34
  return { errors: [`::error::${ctx.repoRoot} is not inside a git repository`] };
31
- }
32
35
  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
36
  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)) };
37
+ const scopes = normalizeScopes(scopesFor(options), ctx.repoRoot);
38
+ const trackedFileSet = new Set([...ctx.trackedFileSet].map((file) => canonicalRepoPath(ctx.repoRoot, file)));
39
+ const baseline = options.baseline && normalizeSccComplexityBaseline(options.baseline, ctx.repoRoot);
40
+ const results = await runScopes(ctx, options, scopes, dir, trackedFileSet, runScc);
41
+ if (baseline)
42
+ validateSccComplexityBaseline(baseline, scopes, trackedFileSet, new Map(results.map(({ scope, values }) => [scope.name, values])));
43
+ return { errors: formatViolations(results, baseline) };
40
44
  }
41
45
  catch (error) {
42
46
  const message = error instanceof Error ? error.message : String(error);
@@ -47,44 +51,77 @@ export async function checkSccComplexity(ctx, options = {}, runScc) {
47
51
  }
48
52
  }
49
53
  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
- }
54
+ return parseSccComplexityValues(json, trackedFileSet)
55
+ .filter((value) => value.complexity > limit)
56
+ .toSorted(compare);
57
+ }
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);
68
73
  }
69
- return violations.toSorted((a, b) => b.complexity - a.complexity || a.file.localeCompare(b.file));
74
+ return options.scopes;
70
75
  }
71
- async function runSccJson(repoRoot, outputPath, options) {
72
- try {
73
- await execFileAsync(options.command ?? 'scc', [...buildSccArgs(options), '--output', outputPath], {
74
- cwd: repoRoot,
75
- maxBuffer: 1024 * 1024,
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
+ async function runScopes(ctx, options, scopes, dir, trackedFileSet, runScc) {
85
+ const results = [];
86
+ for (const [index, scope] of scopes.entries()) {
87
+ const outputPath = join(dir, `scope-${index}.json`);
88
+ const resolve = runScc ?? ((path) => runSccJson(ctx.repoRoot, path, options, scope));
89
+ results.push({
90
+ scope,
91
+ values: parseSccComplexityValues(await resolve(outputPath, scope), trackedFileSet, ctx.repoRoot),
76
92
  });
77
93
  }
94
+ return results;
95
+ }
96
+ function formatViolations(results, baseline) {
97
+ const entries = baselineEntryByScopeAndFile(baseline);
98
+ return results.flatMap(({ scope, values }) => values.flatMap((value) => {
99
+ const limit = scope.limit ?? SCC_COMPLEXITY_LIMIT;
100
+ if (value.complexity <= limit)
101
+ return [];
102
+ const entry = entries.get(scope.name)?.get(value.file);
103
+ if (entry && value.complexity <= entry.complexity)
104
+ return [];
105
+ const prefix = scope.name ? `[${scope.name}] ` : '';
106
+ const detail = entry
107
+ ? `scc complexity ${value.complexity} exceeds baseline ceiling ${entry.complexity}`
108
+ : `scc complexity ${value.complexity} exceeds ${limit}; simplify or split this file`;
109
+ return [`::error file=${value.file}::${prefix}${value.file}: ${detail}`];
110
+ }));
111
+ }
112
+ async function runSccJson(repoRoot, outputPath, options, scope) {
113
+ try {
114
+ await execFileAsync(options.command ?? 'scc', [...buildSccArgs({ ...options, ...scope }), ...scope.includePaths, '--output', outputPath], { cwd: repoRoot, maxBuffer: 1024 * 1024 });
115
+ }
78
116
  catch (error) {
79
- if (isNodeSystemError(error) && error.code === 'ENOENT') {
117
+ if (isNodeSystemError(error) && error.code === 'ENOENT')
80
118
  throw new Error('scc executable not found; install with mise install', { cause: error });
81
- }
82
119
  throw error;
83
120
  }
84
121
  return readFile(outputPath, 'utf8');
85
122
  }
86
- function formatViolation({ file, complexity }, limit) {
87
- return `::error file=${file}::${file}: scc complexity ${complexity} exceeds ${limit}; simplify or split this file`;
123
+ function compare(a, b) {
124
+ return b.complexity - a.complexity || a.file.localeCompare(b.file);
88
125
  }
89
126
  function isNodeSystemError(error) {
90
127
  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,3 @@
1
+ export declare function canonicalRepoPath(repoRoot: string, path: string): string;
2
+ export declare function isInsideRepo(path: string): boolean;
3
+ export declare function isInScope(file: string, includePaths: readonly string[]): boolean;
@@ -0,0 +1,11 @@
1
+ import { relative, resolve } from 'node:path';
2
+ export function canonicalRepoPath(repoRoot, path) {
3
+ const relativePath = relative(repoRoot, resolve(repoRoot, path.replaceAll('\\', '/'))).replaceAll('\\', '/');
4
+ return relativePath || '.';
5
+ }
6
+ export function isInsideRepo(path) {
7
+ return path !== '..' && !path.startsWith('../');
8
+ }
9
+ export function isInScope(file, includePaths) {
10
+ return includePaths.some((includePath) => includePath === '.' || file === includePath || file.startsWith(`${includePath}/`));
11
+ }
@@ -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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
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",
@@ -294,10 +299,14 @@
294
299
  "typecheck": "tsc --noEmit --project tsconfig.json"
295
300
  },
296
301
  "dependencies": {
302
+ "@types/mdast": "4.0.4",
303
+ "@types/unist": "3.0.3",
297
304
  "csv-parse": "7.0.2",
298
305
  "csv-stringify": "6.8.3",
299
306
  "dockerfile-ast": "0.7.1",
300
307
  "picomatch": "4.0.7",
308
+ "remark": "15.0.1",
309
+ "remark-gfm": "4.0.1",
301
310
  "smol-toml": "1.8.0",
302
311
  "yaml": "2.9.0"
303
312
  },