@makehash/nocap 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.ko.md +108 -0
- package/README.md +111 -2
- package/dist/application/coverage/coverage-check.result.d.ts +109 -0
- package/dist/application/coverage/coverage-check.result.js +93 -0
- package/dist/application/coverage/coverage-check.usecase.d.ts +23 -0
- package/dist/application/coverage/coverage-check.usecase.js +76 -0
- package/dist/application/file/file.finder.d.ts +12 -0
- package/dist/application/file/file.finder.js +1 -0
- package/dist/application/file/file.reader.d.ts +21 -0
- package/dist/application/file/file.reader.js +1 -0
- package/dist/application/spec/spec-document.parser.d.ts +11 -0
- package/dist/application/spec/spec-document.parser.js +1 -0
- package/dist/application/test/test-file.parser.d.ts +37 -0
- package/dist/application/test/test-file.parser.js +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +20 -0
- package/dist/domain/coverage/coverage-problem.d.ts +24 -0
- package/dist/domain/coverage/coverage-problem.js +1 -0
- package/dist/domain/coverage/coverage.d.ts +15 -0
- package/dist/domain/coverage/coverage.js +46 -0
- package/dist/domain/shared/id-candidates.d.ts +22 -0
- package/dist/domain/shared/id-candidates.js +34 -0
- package/dist/domain/shared/location.d.ts +8 -0
- package/dist/domain/shared/location.js +22 -0
- package/dist/domain/shared/tag-site.boundary.d.ts +21 -0
- package/dist/domain/shared/tag-site.boundary.js +1 -0
- package/dist/domain/spec/spec-document.d.ts +8 -0
- package/dist/domain/spec/spec-document.js +26 -0
- package/dist/domain/spec/spec-id.d.ts +32 -0
- package/dist/domain/spec/spec-id.js +36 -0
- package/dist/domain/spec/spec-ids.d.ts +8 -0
- package/dist/domain/spec/spec-ids.js +18 -0
- package/dist/domain/spec/spec-item.d.ts +30 -0
- package/dist/domain/spec/spec-item.js +34 -0
- package/dist/domain/spec/spec-tag-site.boundary.d.ts +5 -0
- package/dist/domain/spec/spec-tag-site.boundary.js +1 -0
- package/dist/domain/spec/tag.d.ts +21 -0
- package/dist/domain/spec/tag.js +34 -0
- package/dist/domain/test/test-file.d.ts +8 -0
- package/dist/domain/test/test-file.js +26 -0
- package/dist/domain/test/test-item.d.ts +22 -0
- package/dist/domain/test/test-item.js +29 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/infrastructure/filesystem/file.fs.finder.d.ts +6 -0
- package/dist/infrastructure/filesystem/file.fs.finder.js +17 -0
- package/dist/infrastructure/filesystem/file.fs.reader.d.ts +6 -0
- package/dist/infrastructure/filesystem/file.fs.reader.js +20 -0
- package/dist/infrastructure/markdown/spec-document.markdown.parser.d.ts +5 -0
- package/dist/infrastructure/markdown/spec-document.markdown.parser.js +61 -0
- package/dist/infrastructure/typescript/test-file.typescript.parser.d.ts +4 -0
- package/dist/infrastructure/typescript/test-file.typescript.parser.js +110 -0
- package/dist/lib/array-length.d.ts +2 -0
- package/dist/lib/array-length.js +6 -0
- package/dist/presentation/cli/coverage-check.command.d.ts +19 -0
- package/dist/presentation/cli/coverage-check.command.js +51 -0
- package/dist/presentation/coverage-check.text.formatter.d.ts +16 -0
- package/dist/presentation/coverage-check.text.formatter.js +143 -0
- package/package.json +65 -4
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SpecDocumentParser } from '../../application/spec/spec-document.parser.js';
|
|
2
|
+
import type { SpecTagSite } from '../../domain/spec/spec-tag-site.boundary.js';
|
|
3
|
+
export declare class SpecDocumentMarkdownParser implements SpecDocumentParser {
|
|
4
|
+
parse(content: string): SpecTagSite[];
|
|
5
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
2
|
+
const COMMENT_OPEN = '<!--';
|
|
3
|
+
const COMMENT_CLOSE = '-->';
|
|
4
|
+
export class SpecDocumentMarkdownParser {
|
|
5
|
+
parse(content) {
|
|
6
|
+
return listItemsInDocumentOrder(fromMarkdown(content))
|
|
7
|
+
.flatMap((item) => commentedListItemOf(item) ?? [])
|
|
8
|
+
.map((commented) => tagSiteOf(commented, content));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function listItemsInDocumentOrder(document) {
|
|
12
|
+
return descendantsOf(document).filter((node) => node.type === 'listItem');
|
|
13
|
+
}
|
|
14
|
+
// A parent comes before its children, which is the order the nodes appear in the document.
|
|
15
|
+
function descendantsOf(node) {
|
|
16
|
+
const children = 'children' in node ? node.children : [];
|
|
17
|
+
return children.flatMap((child) => [child, ...descendantsOf(child)]);
|
|
18
|
+
}
|
|
19
|
+
function commentedListItemOf(item) {
|
|
20
|
+
const paragraph = firstParagraphOf(item);
|
|
21
|
+
if (paragraph === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
const comment = trailingCommentOf(paragraph);
|
|
24
|
+
if (comment === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
return { item, paragraph, comment };
|
|
27
|
+
}
|
|
28
|
+
function firstParagraphOf(item) {
|
|
29
|
+
const first = item.children.find((child) => child.type !== 'list');
|
|
30
|
+
return first?.type === 'paragraph' ? first : undefined;
|
|
31
|
+
}
|
|
32
|
+
function trailingCommentOf(paragraph) {
|
|
33
|
+
const last = paragraph.children.at(-1);
|
|
34
|
+
const isComment = last?.type === 'html' && last.value.startsWith(COMMENT_OPEN) && last.value.endsWith(COMMENT_CLOSE);
|
|
35
|
+
return isComment ? last : undefined;
|
|
36
|
+
}
|
|
37
|
+
function tagSiteOf({ item, paragraph, comment }, content) {
|
|
38
|
+
return {
|
|
39
|
+
line: startOf(item).line,
|
|
40
|
+
text: sentenceOf(paragraph, comment, content),
|
|
41
|
+
comment: { line: startOf(comment).line, body: commentBodyOf(comment) },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function sentenceOf(paragraph, comment, content) {
|
|
45
|
+
return content
|
|
46
|
+
.slice(startOf(paragraph).offset, startOf(comment).offset)
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((line) => line.trim())
|
|
49
|
+
.filter((line) => line !== '')
|
|
50
|
+
.join(' ');
|
|
51
|
+
}
|
|
52
|
+
function commentBodyOf(comment) {
|
|
53
|
+
return comment.value.slice(COMMENT_OPEN.length, -COMMENT_CLOSE.length);
|
|
54
|
+
}
|
|
55
|
+
function startOf(node) {
|
|
56
|
+
const start = node.position?.start;
|
|
57
|
+
if (start?.offset === undefined) {
|
|
58
|
+
throw new Error(`Parsed markdown node has no position: ${node.type}`);
|
|
59
|
+
}
|
|
60
|
+
return { line: start.line, offset: start.offset };
|
|
61
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
import { parseSync, Visitor } from 'oxc-parser';
|
|
3
|
+
import { isNonEmpty } from '../../lib/array-length.js';
|
|
4
|
+
const LANGUAGE_ENTRIES = [
|
|
5
|
+
['.ts', 'ts'],
|
|
6
|
+
['.mts', 'ts'],
|
|
7
|
+
['.cts', 'ts'],
|
|
8
|
+
['.tsx', 'tsx'],
|
|
9
|
+
['.js', 'js'],
|
|
10
|
+
['.mjs', 'js'],
|
|
11
|
+
['.cjs', 'js'],
|
|
12
|
+
['.jsx', 'jsx'],
|
|
13
|
+
];
|
|
14
|
+
const SUPPORTED_LANGUAGES = new Map(LANGUAGE_ENTRIES);
|
|
15
|
+
const [[FIRST_EXTENSION], ...OTHER_LANGUAGE_ENTRIES] = LANGUAGE_ENTRIES;
|
|
16
|
+
const SUPPORTED_EXTENSIONS = [FIRST_EXTENSION, ...OTHER_LANGUAGE_ENTRIES.map(([extension]) => extension)];
|
|
17
|
+
const TEST_FUNCTIONS = new Set(['it', 'test', 'describe', 'suite']);
|
|
18
|
+
export class TestFileTypescriptParser {
|
|
19
|
+
parse(path, content) {
|
|
20
|
+
const extension = extname(path);
|
|
21
|
+
const language = SUPPORTED_LANGUAGES.get(extension);
|
|
22
|
+
if (language === undefined) {
|
|
23
|
+
return { kind: 'unsupported-extension', extension, supportedExtensions: SUPPORTED_EXTENSIONS };
|
|
24
|
+
}
|
|
25
|
+
const { program, comments, errors } = parseSync(path, content, { lang: language });
|
|
26
|
+
const messages = errors.map((error) => error.message);
|
|
27
|
+
if (isNonEmpty(messages)) {
|
|
28
|
+
return { kind: 'syntax-error', messages };
|
|
29
|
+
}
|
|
30
|
+
const lines = sourceLinesOf(content);
|
|
31
|
+
const outermostCalls = outermostCallsByLine(program, lines);
|
|
32
|
+
const sites = ownLineCommentsOf(comments, lines)
|
|
33
|
+
.flatMap((comment) => commentedTestCallOf(comment, outermostCalls, lines) ?? [])
|
|
34
|
+
.map((commented) => tagSiteOf(commented, lines));
|
|
35
|
+
return { kind: 'parsed', sites };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// `test.each([1])('x', fn)` has two calls that start at the same offset. The outer call ends later.
|
|
39
|
+
function outermostCallsByLine(program, lines) {
|
|
40
|
+
const outermost = new Map();
|
|
41
|
+
new Visitor({
|
|
42
|
+
CallExpression: (call) => {
|
|
43
|
+
const line = lineOf(call, lines);
|
|
44
|
+
const current = outermost.get(line);
|
|
45
|
+
if (current === undefined || encloses(call, current))
|
|
46
|
+
outermost.set(line, call);
|
|
47
|
+
},
|
|
48
|
+
}).visit(program);
|
|
49
|
+
return outermost;
|
|
50
|
+
}
|
|
51
|
+
function encloses(outer, inner) {
|
|
52
|
+
return outer.start < inner.start || (outer.start === inner.start && outer.end > inner.end);
|
|
53
|
+
}
|
|
54
|
+
function ownLineCommentsOf(comments, lines) {
|
|
55
|
+
return comments.filter((comment) => comment.type === 'Line' && startsLine(comment, lines));
|
|
56
|
+
}
|
|
57
|
+
function commentedTestCallOf(comment, outermostCalls, lines) {
|
|
58
|
+
const call = outermostCalls.get(lineOf(comment, lines) + 1);
|
|
59
|
+
if (call === undefined || !isTestCall(call))
|
|
60
|
+
return undefined;
|
|
61
|
+
return { call, comment };
|
|
62
|
+
}
|
|
63
|
+
function isTestCall(call) {
|
|
64
|
+
const root = rootNameOf(call.callee);
|
|
65
|
+
return root !== undefined && TEST_FUNCTIONS.has(root);
|
|
66
|
+
}
|
|
67
|
+
function rootNameOf(node) {
|
|
68
|
+
switch (node.type) {
|
|
69
|
+
case 'Identifier':
|
|
70
|
+
return node.name;
|
|
71
|
+
case 'MemberExpression':
|
|
72
|
+
return rootNameOf(node.object);
|
|
73
|
+
case 'CallExpression':
|
|
74
|
+
return rootNameOf(node.callee);
|
|
75
|
+
case 'TaggedTemplateExpression':
|
|
76
|
+
return rootNameOf(node.tag);
|
|
77
|
+
default:
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function tagSiteOf({ call, comment }, lines) {
|
|
82
|
+
return {
|
|
83
|
+
line: lineOf(call, lines),
|
|
84
|
+
comment: { line: lineOf(comment, lines), body: comment.value },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function sourceLinesOf(content) {
|
|
88
|
+
const starts = [0];
|
|
89
|
+
for (let newline = content.indexOf('\n'); newline !== -1; newline = content.indexOf('\n', newline + 1)) {
|
|
90
|
+
starts.push(newline + 1);
|
|
91
|
+
}
|
|
92
|
+
return { content, starts };
|
|
93
|
+
}
|
|
94
|
+
function startsLine(node, lines) {
|
|
95
|
+
const lineStart = lines.starts[lineOf(node, lines) - 1] ?? 0;
|
|
96
|
+
return lines.content.slice(lineStart, node.start).trim() === '';
|
|
97
|
+
}
|
|
98
|
+
// Binary search for the last line that starts at or before the node.
|
|
99
|
+
function lineOf(node, lines) {
|
|
100
|
+
let low = 0;
|
|
101
|
+
let high = lines.starts.length - 1;
|
|
102
|
+
while (low < high) {
|
|
103
|
+
const middle = Math.ceil((low + high) / 2);
|
|
104
|
+
if ((lines.starts[middle] ?? Infinity) <= node.start)
|
|
105
|
+
low = middle;
|
|
106
|
+
else
|
|
107
|
+
high = middle - 1;
|
|
108
|
+
}
|
|
109
|
+
return low + 1;
|
|
110
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CoverageCheckResult, UnmatchedPatterns } from '../../application/coverage/coverage-check.result.js';
|
|
2
|
+
import type { CoverageCheckInput } from '../../application/coverage/coverage-check.usecase.js';
|
|
3
|
+
import type { CoverageCheckTextFormatter } from '../coverage-check.text.formatter.js';
|
|
4
|
+
export type CommandOutput = {
|
|
5
|
+
readonly stdout: string;
|
|
6
|
+
readonly stderr: string;
|
|
7
|
+
readonly exitCode: 0 | 1 | 2;
|
|
8
|
+
};
|
|
9
|
+
type CoverageCheck = {
|
|
10
|
+
run(input: CoverageCheckInput): Promise<CoverageCheckResult | UnmatchedPatterns>;
|
|
11
|
+
};
|
|
12
|
+
export declare class CoverageCheckCommand {
|
|
13
|
+
private readonly coverageCheck;
|
|
14
|
+
private readonly formatter;
|
|
15
|
+
constructor(coverageCheck: CoverageCheck, formatter: CoverageCheckTextFormatter);
|
|
16
|
+
run(args: readonly string[]): Promise<CommandOutput>;
|
|
17
|
+
private parse;
|
|
18
|
+
}
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { inspect, parseArgs } from 'node:util';
|
|
2
|
+
import { isNonEmpty } from '../../lib/array-length.js';
|
|
3
|
+
const USAGE = 'Usage: nocap --spec <glob> [--spec <glob> ...] --test <glob> [--test <glob> ...]';
|
|
4
|
+
export class CoverageCheckCommand {
|
|
5
|
+
coverageCheck;
|
|
6
|
+
formatter;
|
|
7
|
+
constructor(coverageCheck, formatter) {
|
|
8
|
+
this.coverageCheck = coverageCheck;
|
|
9
|
+
this.formatter = formatter;
|
|
10
|
+
}
|
|
11
|
+
async run(args) {
|
|
12
|
+
const parsed = this.parse(args);
|
|
13
|
+
if (parsed.kind === 'usage-error')
|
|
14
|
+
return { stdout: '', stderr: `nocap: ${parsed.message}\n${USAGE}\n`, exitCode: 2 };
|
|
15
|
+
try {
|
|
16
|
+
const result = await this.coverageCheck.run(parsed.input);
|
|
17
|
+
if (result.kind === 'unmatched-patterns')
|
|
18
|
+
return { stdout: '', stderr: unmatchedPatternsMessage(result), exitCode: 2 };
|
|
19
|
+
return { ...this.formatter.format(result), exitCode: result.hasProblems() ? 1 : 0 };
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
return { stdout: '', stderr: internalErrorMessage(error), exitCode: 2 };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
parse(args) {
|
|
26
|
+
try {
|
|
27
|
+
const { values } = parseArgs({
|
|
28
|
+
args: [...args],
|
|
29
|
+
options: { spec: { type: 'string', multiple: true }, test: { type: 'string', multiple: true } },
|
|
30
|
+
strict: true,
|
|
31
|
+
allowPositionals: false,
|
|
32
|
+
});
|
|
33
|
+
if (!isNonEmpty(values.spec))
|
|
34
|
+
return { kind: 'usage-error', message: 'missing --spec' };
|
|
35
|
+
if (!isNonEmpty(values.test))
|
|
36
|
+
return { kind: 'usage-error', message: 'missing --test' };
|
|
37
|
+
return { kind: 'parsed', input: { specPatterns: values.spec, testPatterns: values.test } };
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return { kind: 'usage-error', message: error instanceof Error ? error.message : String(error) };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const OPTION_OF_SIDE = { spec: '--spec', test: '--test' };
|
|
45
|
+
function unmatchedPatternsMessage({ patterns }) {
|
|
46
|
+
return patterns.map(({ side, pattern }) => `nocap: no files match ${OPTION_OF_SIDE[side]} ${JSON.stringify(pattern)}\n`).join('');
|
|
47
|
+
}
|
|
48
|
+
function internalErrorMessage(error) {
|
|
49
|
+
const detail = error instanceof Error ? `${error.message}\n${inspect(error)}` : inspect(error);
|
|
50
|
+
return `nocap: internal error: ${detail}\n`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CoverageCheckResult } from '../application/coverage/coverage-check.result.js';
|
|
2
|
+
export type ColorSupport = {
|
|
3
|
+
readonly stdout: boolean;
|
|
4
|
+
readonly stderr: boolean;
|
|
5
|
+
};
|
|
6
|
+
export type FormattedOutput = {
|
|
7
|
+
readonly stdout: string;
|
|
8
|
+
readonly stderr: string;
|
|
9
|
+
};
|
|
10
|
+
export declare class CoverageCheckTextFormatter {
|
|
11
|
+
private readonly colors;
|
|
12
|
+
constructor(colors?: ColorSupport);
|
|
13
|
+
format(result: CoverageCheckResult): FormattedOutput;
|
|
14
|
+
private stdoutOf;
|
|
15
|
+
private stderrOf;
|
|
16
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const DIAGNOSTIC_KINDS = [
|
|
2
|
+
'uncovered-spec',
|
|
3
|
+
'unknown-id',
|
|
4
|
+
'duplicate-id',
|
|
5
|
+
'invalid-id',
|
|
6
|
+
'missing-id',
|
|
7
|
+
'multiple-ids',
|
|
8
|
+
'read-error',
|
|
9
|
+
'unsupported-extension',
|
|
10
|
+
'syntax-error',
|
|
11
|
+
];
|
|
12
|
+
const ANSI = { bold: '1', dim: '2', red: '31', green: '32', yellow: '33', cyan: '36' };
|
|
13
|
+
const ID_CHARS_HINT = 'use only A-Z, a-z, 0-9, "-", "_"';
|
|
14
|
+
export class CoverageCheckTextFormatter {
|
|
15
|
+
colors;
|
|
16
|
+
constructor(colors = { stdout: false, stderr: false }) {
|
|
17
|
+
this.colors = colors;
|
|
18
|
+
}
|
|
19
|
+
format(result) {
|
|
20
|
+
const diagnostics = inReportOrder([
|
|
21
|
+
...result.coverageProblems.flatMap((problem) => coverageProblemDiagnostics(problem)),
|
|
22
|
+
...result.invalidTags.flatMap((invalidTag) => invalidTagDiagnostics(invalidTag)),
|
|
23
|
+
...result.fileErrors.flatMap((fileError) => fileErrorDiagnostics(fileError)),
|
|
24
|
+
]);
|
|
25
|
+
return { stdout: this.stdoutOf(diagnostics), stderr: this.stderrOf(diagnostics, result) };
|
|
26
|
+
}
|
|
27
|
+
stdoutOf(diagnostics) {
|
|
28
|
+
const paint = painter(this.colors.stdout);
|
|
29
|
+
return diagnostics
|
|
30
|
+
.map((diagnostic, index) => {
|
|
31
|
+
const samePathAsPrevious = index > 0 && diagnostics[index - 1]?.path === diagnostic.path;
|
|
32
|
+
const location = paint(samePathAsPrevious ? [ANSI.dim] : [ANSI.cyan], diagnostic.path) +
|
|
33
|
+
(diagnostic.line === undefined ? '' : `:${paint([ANSI.bold], String(diagnostic.line))}`);
|
|
34
|
+
const message = diagnostic.message.map((segment) => (segment.isValue ? paint([ANSI.yellow], segment.text) : segment.text)).join('');
|
|
35
|
+
const hint = diagnostic.hint === undefined ? '' : paint([ANSI.dim], `; hint: ${diagnostic.hint}`);
|
|
36
|
+
return `${location}: ${paint([ANSI.red, ANSI.bold], 'error')}${paint([ANSI.red], `[${diagnostic.kind}]`)}: ${message}${hint}\n`;
|
|
37
|
+
})
|
|
38
|
+
.join('');
|
|
39
|
+
}
|
|
40
|
+
stderrOf(diagnostics, result) {
|
|
41
|
+
const paint = painter(this.colors.stderr);
|
|
42
|
+
const coverageLine = paint([result.isFullyCovered() ? ANSI.green : ANSI.yellow], `${result.coveredSpecCount} of ${result.specCount} specs covered`);
|
|
43
|
+
if (diagnostics.length === 0)
|
|
44
|
+
return `${coverageLine}\n`;
|
|
45
|
+
return `${paint([ANSI.red, ANSI.bold], errorsLineOf(diagnostics))}\n${coverageLine}\n`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function coverageProblemDiagnostics(problem) {
|
|
49
|
+
switch (problem.kind) {
|
|
50
|
+
case 'uncovered-spec': {
|
|
51
|
+
const text = singleLine(problem.text);
|
|
52
|
+
const message = [value(problem.id), plain(' has no test'), ...(text === '' ? [] : [plain(`: ${text}`)])];
|
|
53
|
+
return [diagnosticAt(problem.location, 'uncovered-spec', message)];
|
|
54
|
+
}
|
|
55
|
+
case 'unknown-id':
|
|
56
|
+
return problem.testLocations.map((location) => diagnosticAt(location, 'unknown-id', [value(problem.id), plain(' is not defined in any spec')]));
|
|
57
|
+
case 'duplicate-id':
|
|
58
|
+
return problem.specLocations.map((location, index) => {
|
|
59
|
+
const others = problem.specLocations.filter((_, otherIndex) => otherIndex !== index).map(locationText);
|
|
60
|
+
return diagnosticAt(location, 'duplicate-id', [value(problem.id), plain(` is also defined at ${others.join(', ')}`)]);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function invalidTagDiagnostics(invalidTag) {
|
|
65
|
+
switch (invalidTag.kind) {
|
|
66
|
+
case 'id-rule-violation':
|
|
67
|
+
return invalidTag.invalidCandidates.map((invalidCandidate) => invalidIdDiagnostic(invalidTag.location, invalidCandidate));
|
|
68
|
+
case 'missing-id':
|
|
69
|
+
return [diagnosticAt(invalidTag.location, 'missing-id', [plain('missing id in the tag')], 'write an id, e.g. "nocap: AUTH-001"')];
|
|
70
|
+
case 'multiple-ids': {
|
|
71
|
+
const ids = invalidTag.ids.flatMap((id, index) => (index === 0 ? [value(id)] : [plain(', '), value(id)]));
|
|
72
|
+
const message = [plain(`spec tag has ${invalidTag.ids.length} ids `), ...ids];
|
|
73
|
+
return [diagnosticAt(invalidTag.location, 'multiple-ids', message, 'a spec item takes 1 id')];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function invalidIdDiagnostic(location, invalidCandidate) {
|
|
78
|
+
switch (invalidCandidate.kind) {
|
|
79
|
+
case 'invalid-whitespace':
|
|
80
|
+
return diagnosticAt(location, 'invalid-id', [value(invalidCandidate.value), plain(' has a space')], 'separate ids with ","');
|
|
81
|
+
case 'invalid-char':
|
|
82
|
+
return diagnosticAt(location, 'invalid-id', [value(invalidCandidate.value), plain(' has '), value(invalidCandidate.firstInvalidChar)], ID_CHARS_HINT);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function fileErrorDiagnostics(fileError) {
|
|
86
|
+
switch (fileError.kind) {
|
|
87
|
+
case 'read-error':
|
|
88
|
+
return [diagnosticOfFile(fileError.path, 'read-error', [plain(`cannot read file: ${singleLine(fileError.message)}`)])];
|
|
89
|
+
case 'unsupported-extension':
|
|
90
|
+
return [
|
|
91
|
+
diagnosticOfFile(fileError.path, 'unsupported-extension', [value(fileError.extension), plain(' is not a supported test file extension')], `use ${choicesOf(fileError.supportedExtensions)}`),
|
|
92
|
+
];
|
|
93
|
+
case 'syntax-error':
|
|
94
|
+
return fileError.messages.map((message) => diagnosticOfFile(fileError.path, 'syntax-error', [plain(singleLine(message))]));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function errorsLineOf(diagnostics) {
|
|
98
|
+
const fileCount = new Set(diagnostics.map((diagnostic) => diagnostic.path)).size;
|
|
99
|
+
const kindCounts = DIAGNOSTIC_KINDS.map((kind) => ({
|
|
100
|
+
kind,
|
|
101
|
+
count: diagnostics.filter((diagnostic) => diagnostic.kind === kind).length,
|
|
102
|
+
}))
|
|
103
|
+
.filter(({ count }) => count > 0)
|
|
104
|
+
.map(({ kind, count }) => `${count} ${kind}`);
|
|
105
|
+
return `${countOf(diagnostics.length, 'error')} in ${countOf(fileCount, 'file')}: ${kindCounts.join(', ')}`;
|
|
106
|
+
}
|
|
107
|
+
function diagnosticAt(location, kind, message, hint) {
|
|
108
|
+
return { path: location.path, line: location.line, kind, message, hint };
|
|
109
|
+
}
|
|
110
|
+
function diagnosticOfFile(path, kind, message, hint) {
|
|
111
|
+
return { path, line: undefined, kind, message, hint };
|
|
112
|
+
}
|
|
113
|
+
function inReportOrder(diagnostics) {
|
|
114
|
+
return [...diagnostics].sort((a, b) => {
|
|
115
|
+
if (a.path !== b.path)
|
|
116
|
+
return a.path < b.path ? -1 : 1;
|
|
117
|
+
return (a.line ?? 0) - (b.line ?? 0);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function value(text) {
|
|
121
|
+
return { text: JSON.stringify(text), isValue: true };
|
|
122
|
+
}
|
|
123
|
+
function plain(text) {
|
|
124
|
+
return { text, isValue: false };
|
|
125
|
+
}
|
|
126
|
+
function singleLine(text) {
|
|
127
|
+
return text.replace(/\r\n|\r|\n/g, ' ');
|
|
128
|
+
}
|
|
129
|
+
function locationText(location) {
|
|
130
|
+
return `${location.path}:${location.line}`;
|
|
131
|
+
}
|
|
132
|
+
function countOf(count, noun) {
|
|
133
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
134
|
+
}
|
|
135
|
+
function painter(enabled) {
|
|
136
|
+
return (codes, text) => (enabled ? `\u001b[${codes.join(';')}m${text}\u001b[0m` : text);
|
|
137
|
+
}
|
|
138
|
+
function choicesOf([first, ...rest]) {
|
|
139
|
+
const last = rest.at(-1);
|
|
140
|
+
if (last === undefined)
|
|
141
|
+
return first;
|
|
142
|
+
return `${[first, ...rest.slice(0, -1)].join(', ')} or ${last}`;
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makehash/nocap",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Link spec docs to tests, and track which specs are not verified yet",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"spec",
|
|
7
|
+
"coverage",
|
|
8
|
+
"traceability",
|
|
9
|
+
"requirements",
|
|
10
|
+
"testing",
|
|
11
|
+
"markdown",
|
|
12
|
+
"cli"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "hash_kim",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/makeUgreat/nocap.git",
|
|
19
|
+
"directory": "packages/nocap"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/makeUgreat/nocap#readme",
|
|
22
|
+
"bugs": "https://github.com/makeUgreat/nocap/issues",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"nocap": "dist/bin.js"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22.12"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"",
|
|
44
|
+
"build": "tsc -p tsconfig.build.json",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"test:unit": "vitest run --project unit",
|
|
48
|
+
"test:integration": "vitest run --project integration",
|
|
49
|
+
"lint:deps": "depcruise src --config dependency-cruiser/config.ts",
|
|
50
|
+
"lint:package": "publint && attw --pack . --profile esm-only"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
54
|
+
"@swc/core": "^1.16.21",
|
|
55
|
+
"@types/mdast": "^4.0.4",
|
|
56
|
+
"@types/node": "^22.20.5",
|
|
57
|
+
"dependency-cruiser": "^18.7.0",
|
|
58
|
+
"publint": "^0.3.25",
|
|
59
|
+
"typescript": "^7.0.2",
|
|
60
|
+
"vitest": "^5.0.5"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"mdast-util-from-markdown": "^2.1.0",
|
|
64
|
+
"oxc-parser": "^0.156.0",
|
|
65
|
+
"tinyglobby": "^0.2.17"
|
|
66
|
+
}
|
|
67
|
+
}
|