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 +21 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/dist/markdown/ast.d.mts +8 -0
- package/dist/markdown/ast.mjs +29 -0
- package/dist/markdown/index.d.mts +6 -0
- package/dist/markdown/index.mjs +5 -0
- package/dist/markdown/loose-pipe-rows.d.mts +2 -0
- package/dist/markdown/loose-pipe-rows.mjs +31 -0
- package/dist/markdown/sections.d.mts +2 -0
- package/dist/markdown/sections.mjs +25 -0
- package/dist/markdown/tables.d.mts +3 -0
- package/dist/markdown/tables.mjs +43 -0
- package/dist/markdown/text.d.mts +2 -0
- package/dist/markdown/text.mjs +22 -0
- package/dist/markdown/types.d.mts +35 -0
- package/dist/markdown/types.mjs +1 -0
- package/dist/scc-complexity/baseline.d.mts +6 -0
- package/dist/scc-complexity/baseline.mjs +87 -0
- package/dist/scc-complexity/index.d.mts +5 -13
- package/dist/scc-complexity/index.mjs +73 -36
- package/dist/scc-complexity/parser.d.mts +2 -0
- package/dist/scc-complexity/parser.mjs +21 -0
- package/dist/scc-complexity/paths.d.mts +3 -0
- package/dist/scc-complexity/paths.mjs +11 -0
- package/dist/scc-complexity/types.d.mts +32 -0
- package/dist/scc-complexity/types.mjs +1 -0
- package/package.json +10 -1
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,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,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,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
|
-
|
|
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?:
|
|
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
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
74
|
+
return options.scopes;
|
|
70
75
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
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
|
|
87
|
-
return
|
|
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,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,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.
|
|
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
|
},
|