@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +108 -0
  3. package/README.md +111 -2
  4. package/dist/application/coverage/coverage-check.result.d.ts +109 -0
  5. package/dist/application/coverage/coverage-check.result.js +93 -0
  6. package/dist/application/coverage/coverage-check.usecase.d.ts +23 -0
  7. package/dist/application/coverage/coverage-check.usecase.js +76 -0
  8. package/dist/application/file/file.finder.d.ts +12 -0
  9. package/dist/application/file/file.finder.js +1 -0
  10. package/dist/application/file/file.reader.d.ts +21 -0
  11. package/dist/application/file/file.reader.js +1 -0
  12. package/dist/application/spec/spec-document.parser.d.ts +11 -0
  13. package/dist/application/spec/spec-document.parser.js +1 -0
  14. package/dist/application/test/test-file.parser.d.ts +37 -0
  15. package/dist/application/test/test-file.parser.js +1 -0
  16. package/dist/bin.d.ts +2 -0
  17. package/dist/bin.js +20 -0
  18. package/dist/domain/coverage/coverage-problem.d.ts +24 -0
  19. package/dist/domain/coverage/coverage-problem.js +1 -0
  20. package/dist/domain/coverage/coverage.d.ts +15 -0
  21. package/dist/domain/coverage/coverage.js +46 -0
  22. package/dist/domain/shared/id-candidates.d.ts +22 -0
  23. package/dist/domain/shared/id-candidates.js +34 -0
  24. package/dist/domain/shared/location.d.ts +8 -0
  25. package/dist/domain/shared/location.js +22 -0
  26. package/dist/domain/shared/tag-site.boundary.d.ts +21 -0
  27. package/dist/domain/shared/tag-site.boundary.js +1 -0
  28. package/dist/domain/spec/spec-document.d.ts +8 -0
  29. package/dist/domain/spec/spec-document.js +26 -0
  30. package/dist/domain/spec/spec-id.d.ts +32 -0
  31. package/dist/domain/spec/spec-id.js +36 -0
  32. package/dist/domain/spec/spec-ids.d.ts +8 -0
  33. package/dist/domain/spec/spec-ids.js +18 -0
  34. package/dist/domain/spec/spec-item.d.ts +30 -0
  35. package/dist/domain/spec/spec-item.js +34 -0
  36. package/dist/domain/spec/spec-tag-site.boundary.d.ts +5 -0
  37. package/dist/domain/spec/spec-tag-site.boundary.js +1 -0
  38. package/dist/domain/spec/tag.d.ts +21 -0
  39. package/dist/domain/spec/tag.js +34 -0
  40. package/dist/domain/test/test-file.d.ts +8 -0
  41. package/dist/domain/test/test-file.js +26 -0
  42. package/dist/domain/test/test-item.d.ts +22 -0
  43. package/dist/domain/test/test-item.js +29 -0
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +1 -0
  46. package/dist/infrastructure/filesystem/file.fs.finder.d.ts +6 -0
  47. package/dist/infrastructure/filesystem/file.fs.finder.js +17 -0
  48. package/dist/infrastructure/filesystem/file.fs.reader.d.ts +6 -0
  49. package/dist/infrastructure/filesystem/file.fs.reader.js +20 -0
  50. package/dist/infrastructure/markdown/spec-document.markdown.parser.d.ts +5 -0
  51. package/dist/infrastructure/markdown/spec-document.markdown.parser.js +61 -0
  52. package/dist/infrastructure/typescript/test-file.typescript.parser.d.ts +4 -0
  53. package/dist/infrastructure/typescript/test-file.typescript.parser.js +110 -0
  54. package/dist/lib/array-length.d.ts +2 -0
  55. package/dist/lib/array-length.js +6 -0
  56. package/dist/presentation/cli/coverage-check.command.d.ts +19 -0
  57. package/dist/presentation/cli/coverage-check.command.js +51 -0
  58. package/dist/presentation/coverage-check.text.formatter.d.ts +16 -0
  59. package/dist/presentation/coverage-check.text.formatter.js +143 -0
  60. package/package.json +65 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hash_kim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ko.md ADDED
@@ -0,0 +1,108 @@
1
+ <h1 align="center">nocap</h1>
2
+
3
+ <p align="center">명세 문서와 테스트를 연결해 어떤 명세가 아직 검증되지 않았는지 추적합니다.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://github.com/makeUgreat/nocap/actions/workflows/ci.yml"><img src="https://github.com/makeUgreat/nocap/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
7
+ <a href="https://github.com/makeUgreat/nocap/blob/master/packages/nocap/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="Package License" /></a>
8
+ </p>
9
+
10
+ <p align="center"><a href="https://github.com/makeUgreat/nocap/blob/master/packages/nocap/README.md">English</a></p>
11
+
12
+ ## 소개
13
+
14
+ nocap은 Markdown 명세와 테스트를 ID로 연결합니다.
15
+ 명세 항목에 ID를 붙이고 테스트에는 그 테스트가 검증하는 ID를 붙입니다.
16
+
17
+ nocap은 아래와 같은 문제를 찾고 보고합니다.
18
+ - 테스트가 없는 명세 항목
19
+ - 없는 ID를 가리키는 테스트
20
+ - 중복 ID
21
+ - 형식이 틀린 태그
22
+
23
+ > **참고:** nocap은 주석만 읽고 테스트를 실행하지 않습니다.
24
+ > 그래서 `it`, `test`, `describe`, `suite`를 쓰는 테스트 러너라면 어디서든 동작합니다.
25
+
26
+ ## 설치
27
+
28
+ ```bash
29
+ $ npm i --save-dev @makehash/nocap
30
+ ```
31
+
32
+ Node.js 22.12 이상이 필요합니다.
33
+
34
+ ## 사용법
35
+
36
+ ```bash
37
+ $ nocap --spec <glob> --test <glob>
38
+ ```
39
+
40
+ | 옵션 | 값 |
41
+ | --- | --- |
42
+ | `--spec <glob>` | 명세 문서의 glob 패턴. 필수. 여러 번 쓸 수 있음 |
43
+ | `--test <glob>` | 테스트 파일의 glob 패턴. 필수. 여러 번 쓸 수 있음 |
44
+
45
+ nocap은 명령을 실행한 디렉토리를 기준으로 패턴을 찾습니다. 셸이 패턴을 먼저 펼치지 않도록 패턴마다 따옴표로 감쌉니다.
46
+
47
+ 자주 쓰는 명령은 `package.json`의 스크립트로 저장해 둘 수 있습니다. 예:
48
+
49
+ ```json
50
+ {
51
+ "scripts": {
52
+ "nocap": "nocap --spec \"docs/**/*.md\" --test \"src/**/*.test.ts\""
53
+ }
54
+ }
55
+ ```
56
+
57
+ ```bash
58
+ $ npm run nocap
59
+ ```
60
+
61
+ > **주의:** `package.json`에서는 위처럼 이스케이프한 큰따옴표를 씁니다. 작은따옴표는 Windows에서 동작하지 않습니다.
62
+
63
+ ## 빠른 시작
64
+
65
+ 예를 들어 프로젝트에 로그인 잠금 기능이 있고, 규칙이 2개라고 합시다.
66
+
67
+ 1. 5회 연속 실패하면 계정을 잠금
68
+ 2. 잠금은 30분 후 풀림
69
+
70
+ 그런데 테스트는 첫 번째 규칙에만 있습니다. nocap으로 빠진 테스트를 찾는 방법은 아래와 같습니다.
71
+
72
+ **1단계.** 명세 문서 `docs/auth.md`의 규칙마다 ID를 붙입니다.
73
+
74
+ ```md
75
+ ## 로그인 잠금
76
+ - 5회 연속 실패하면 계정을 잠금 <!-- nocap: AUTH-001 -->
77
+ - 잠금은 30분 후 풀림 <!-- nocap: AUTH-002 -->
78
+ ```
79
+
80
+ **2단계.** `src/auth.test.ts`의 테스트에 그 테스트가 검증하는 규칙의 ID를 붙입니다.
81
+
82
+ ```ts
83
+ // nocap: AUTH-001
84
+ it('5번째 실패에서 계정을 잠근다', () => {})
85
+ ```
86
+
87
+ **3단계.** [사용법](#사용법)의 `nocap` 스크립트를 실행합니다.
88
+
89
+ ```bash
90
+ $ npm run nocap
91
+ docs/auth.md:3: error[uncovered-spec]: "AUTH-002" has no test: 잠금은 30분 후 풀림
92
+ 1 error in 1 file: 1 uncovered-spec
93
+ 1 of 2 specs covered
94
+ ```
95
+
96
+ nocap은 두 번째 규칙(`AUTH-002`)에 테스트가 없다고 보고하고 `1`로 끝납니다.
97
+ `// nocap: AUTH-002`를 붙인 테스트를 추가하면 `0`으로 끝납니다.
98
+
99
+ > **팁:** CI에서 nocap을 실행하면 테스트 없는 명세를 남기는 변경을 막을 수 있습니다.
100
+
101
+ ## 문서
102
+
103
+ - [문법](https://github.com/makeUgreat/nocap/blob/master/docs/syntax.md) - 태그를 두는 위치와 ID 규칙
104
+ - [CLI](https://github.com/makeUgreat/nocap/blob/master/docs/specs/cli.md) - 옵션, 출력 형식, 종료 코드
105
+
106
+ ## 라이선스
107
+
108
+ nocap은 [MIT 라이선스](https://github.com/makeUgreat/nocap/blob/master/packages/nocap/LICENSE)를 따릅니다.
package/README.md CHANGED
@@ -1,3 +1,112 @@
1
- # Temporary Holding Version
1
+ <h1 align="center">nocap</h1>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <p align="center">Link spec docs to tests, and track which specs are not verified yet.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://github.com/makeUgreat/nocap/actions/workflows/ci.yml"><img src="https://github.com/makeUgreat/nocap/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
7
+ <a href="https://github.com/makeUgreat/nocap/blob/master/packages/nocap/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="Package License" /></a>
8
+ </p>
9
+
10
+ <p align="center"><a href="https://github.com/makeUgreat/nocap/blob/master/packages/nocap/README.ko.md">한국어</a></p>
11
+
12
+ ## Description
13
+
14
+ nocap links Markdown specs to tests by ID.
15
+
16
+ Tag each spec item with an ID. Tag each test with the IDs it covers. nocap then reports:
17
+
18
+ - spec items with no test
19
+ - tests that reference an unknown ID
20
+ - duplicate IDs
21
+ - malformed tags
22
+
23
+ > **Note:** nocap reads comments only. It does not run your tests, so it works with any runner that uses `it`, `test`, `describe`, or `suite`.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ $ npm i --save-dev @makehash/nocap
29
+ ```
30
+
31
+ Requires Node.js 22.12 or later.
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ $ nocap --spec <glob> --test <glob>
37
+ ```
38
+
39
+ | Option | Value |
40
+ | --- | --- |
41
+ | `--spec <glob>` | Glob for spec documents. Required. Can be repeated. |
42
+ | `--test <glob>` | Glob for test files. Required. Can be repeated. |
43
+
44
+ nocap resolves the globs from the current directory. Put each glob in quotes so that your shell does not expand it first.
45
+
46
+ You can save the command as a script in `package.json`. For example:
47
+
48
+ ```json
49
+ {
50
+ "scripts": {
51
+ "nocap": "nocap --spec \"docs/**/*.md\" --test \"src/**/*.test.ts\""
52
+ }
53
+ }
54
+ ```
55
+
56
+ ```bash
57
+ $ npm run nocap
58
+ ```
59
+
60
+ > **Warning:** In `package.json`, use escaped double quotes as above. Single quotes do not work on Windows.
61
+
62
+ ## Quick Start
63
+
64
+ For example, say your project has a login lockout feature with two rules:
65
+
66
+ 1. Lock the account after 5 failed logins.
67
+ 2. Unlock the account after 30 minutes.
68
+
69
+ You have written a test for the first rule only. Here is how nocap finds the missing one.
70
+
71
+ **Step 1.** Give each rule an ID in the spec document `docs/auth.md`:
72
+
73
+ ```md
74
+ ## Login lockout
75
+ - Lock the account after 5 failed logins <!-- nocap: AUTH-001 -->
76
+ - Unlock the account after 30 minutes <!-- nocap: AUTH-002 -->
77
+ ```
78
+
79
+ **Step 2.** Mark the test with the ID of the rule it verifies, in `src/auth.test.ts`:
80
+
81
+ ```ts
82
+ // nocap: AUTH-001
83
+ it('locks the account on the 5th failure', () => {})
84
+ ```
85
+
86
+ **Step 3.** Run the `nocap` script from [Usage](#usage):
87
+
88
+ ```bash
89
+ $ npm run nocap
90
+ docs/auth.md:3: error[uncovered-spec]: "AUTH-002" has no test: Unlock the account after 30 minutes
91
+ 1 error in 1 file: 1 uncovered-spec
92
+ 1 of 2 specs covered
93
+ ```
94
+
95
+ nocap reports that the second rule (`AUTH-002`) has no test, and exits with `1`. Add a test marked `// nocap: AUTH-002`, and nocap exits with `0`.
96
+
97
+ > **Tip:** Run nocap in CI to block changes that leave a spec without a test.
98
+
99
+ ## Documentation
100
+
101
+ The documentation is in Korean.
102
+
103
+ - [Syntax](https://github.com/makeUgreat/nocap/blob/master/docs/syntax.md) - where to put tags and which IDs are valid
104
+ - [CLI](https://github.com/makeUgreat/nocap/blob/master/docs/specs/cli.md) - options, output format, and exit codes
105
+
106
+ ## Stay in touch
107
+
108
+ - Author - [hash_kim](https://github.com/makeUgreat)
109
+
110
+ ## License
111
+
112
+ nocap is [MIT licensed](https://github.com/makeUgreat/nocap/blob/master/packages/nocap/LICENSE).
@@ -0,0 +1,109 @@
1
+ import type { Coverage as DomainCoverage } from '../../domain/coverage/coverage.js';
2
+ import type { SpecInvalidTag } from '../../domain/spec/spec-item.js';
3
+ import type { TestInvalidTag } from '../../domain/test/test-item.js';
4
+ /** A place in a spec document or a test file. */
5
+ export type Location = {
6
+ readonly path: string;
7
+ /** Starts at 1. */
8
+ readonly line: number;
9
+ };
10
+ /** A place where the link between spec items and test items is broken. */
11
+ export type CoverageProblem = {
12
+ /** A spec item whose id no other spec item has, and that no test item points to. */
13
+ readonly kind: 'uncovered-spec';
14
+ readonly id: string;
15
+ readonly location: Location;
16
+ /** The sentence of the spec item. Empty when the list item has no text. */
17
+ readonly text: string;
18
+ } | {
19
+ /** An id that test items point to, but that no spec item has. */
20
+ readonly kind: 'unknown-id';
21
+ readonly id: string;
22
+ /** Where the test items that point to the id are, in the order of the test items. */
23
+ readonly testLocations: readonly [Location, ...Location[]];
24
+ } | {
25
+ /** An id that two or more spec items have. These spec items are not judged as covered or uncovered. */
26
+ readonly kind: 'duplicate-id';
27
+ readonly id: string;
28
+ /** Where the spec items that have the id are, in the order of the spec items. */
29
+ readonly specLocations: readonly [Location, Location, ...Location[]];
30
+ };
31
+ /** An id candidate that broke the id rule, with the reason taken from the first character the rule does not allow. */
32
+ export type InvalidIdCandidate = {
33
+ readonly kind: 'invalid-whitespace';
34
+ readonly value: string;
35
+ } | {
36
+ readonly kind: 'invalid-char';
37
+ readonly value: string;
38
+ readonly firstInvalidChar: string;
39
+ };
40
+ /** A spec tag or a test tag that broke a rule, at the line where the tag starts. No spec item or test item is made from it. */
41
+ export type InvalidTag = {
42
+ readonly kind: 'id-rule-violation';
43
+ readonly location: Location;
44
+ readonly invalidCandidates: readonly [InvalidIdCandidate, ...InvalidIdCandidate[]];
45
+ } | {
46
+ readonly kind: 'missing-id';
47
+ readonly location: Location;
48
+ } | {
49
+ /** A spec tag has two or more ids. Only spec tags have this reason. */
50
+ readonly kind: 'multiple-ids';
51
+ readonly location: Location;
52
+ readonly ids: readonly [string, string, ...string[]];
53
+ };
54
+ /** A file that took no part in the coverage judgment. */
55
+ export type FileError = {
56
+ readonly kind: 'read-error';
57
+ readonly path: string;
58
+ readonly message: string;
59
+ } | {
60
+ readonly kind: 'unsupported-extension';
61
+ readonly path: string;
62
+ readonly extension: string;
63
+ /** The extensions the test file parser supports, in the order the parser lists them. */
64
+ readonly supportedExtensions: readonly [string, ...string[]];
65
+ } | {
66
+ readonly kind: 'syntax-error';
67
+ readonly path: string;
68
+ readonly messages: readonly [string, ...string[]];
69
+ };
70
+ /** A glob pattern that matched no file, with the side it was given for. */
71
+ export type UnmatchedPattern = {
72
+ readonly side: 'spec' | 'test';
73
+ readonly pattern: string;
74
+ };
75
+ /** Glob patterns that matched no file. When there is one, the check reads no file and judges nothing. */
76
+ export type UnmatchedPatterns = {
77
+ readonly kind: 'unmatched-patterns';
78
+ /** Spec patterns first, then test patterns. Each side is in the order given, and each pattern appears once. */
79
+ readonly patterns: readonly [UnmatchedPattern, ...UnmatchedPattern[]];
80
+ };
81
+ type CoverageCheckResultData = {
82
+ readonly specCount: number;
83
+ readonly coveredSpecCount: number;
84
+ readonly coverageProblems: readonly CoverageProblem[];
85
+ readonly invalidTags: readonly InvalidTag[];
86
+ readonly fileErrors: readonly FileError[];
87
+ };
88
+ /** The result of one coverage check. It holds plain data only, so that code outside application does not depend on the domain model. */
89
+ export declare class CoverageCheckResult {
90
+ readonly specCount: number;
91
+ readonly coveredSpecCount: number;
92
+ /** Spec side problems in the order their ids first appear in the spec items, then unknown ids in the order they first appear in the test items. */
93
+ readonly coverageProblems: readonly CoverageProblem[];
94
+ /** Invalid tags of the spec documents, then those of the test files. Each group is in code unit order of the paths, then in document order. */
95
+ readonly invalidTags: readonly InvalidTag[];
96
+ /** Errors of the spec documents, then those of the test files. Each group is in code unit order of the paths. */
97
+ readonly fileErrors: readonly FileError[];
98
+ readonly kind = "checked";
99
+ private constructor();
100
+ static of(data: CoverageCheckResultData): CoverageCheckResult;
101
+ static fromDomain({ coverage, invalidTags, fileErrors, }: {
102
+ readonly coverage: DomainCoverage;
103
+ readonly invalidTags: readonly (SpecInvalidTag | TestInvalidTag)[];
104
+ readonly fileErrors: readonly FileError[];
105
+ }): CoverageCheckResult;
106
+ hasProblems(): boolean;
107
+ isFullyCovered(): boolean;
108
+ }
109
+ export {};
@@ -0,0 +1,93 @@
1
+ /** The result of one coverage check. It holds plain data only, so that code outside application does not depend on the domain model. */
2
+ export class CoverageCheckResult {
3
+ specCount;
4
+ coveredSpecCount;
5
+ coverageProblems;
6
+ invalidTags;
7
+ fileErrors;
8
+ kind = 'checked';
9
+ constructor(specCount, coveredSpecCount,
10
+ /** Spec side problems in the order their ids first appear in the spec items, then unknown ids in the order they first appear in the test items. */
11
+ coverageProblems,
12
+ /** Invalid tags of the spec documents, then those of the test files. Each group is in code unit order of the paths, then in document order. */
13
+ invalidTags,
14
+ /** Errors of the spec documents, then those of the test files. Each group is in code unit order of the paths. */
15
+ fileErrors) {
16
+ this.specCount = specCount;
17
+ this.coveredSpecCount = coveredSpecCount;
18
+ this.coverageProblems = coverageProblems;
19
+ this.invalidTags = invalidTags;
20
+ this.fileErrors = fileErrors;
21
+ }
22
+ static of(data) {
23
+ return new CoverageCheckResult(data.specCount, data.coveredSpecCount, data.coverageProblems, data.invalidTags, data.fileErrors);
24
+ }
25
+ static fromDomain({ coverage, invalidTags, fileErrors, }) {
26
+ return CoverageCheckResult.of({
27
+ specCount: coverage.specCount,
28
+ coveredSpecCount: coverage.coveredSpecCount,
29
+ coverageProblems: coverage.problems.map(coverageProblemOf),
30
+ invalidTags: invalidTags.map(invalidTagOf),
31
+ fileErrors,
32
+ });
33
+ }
34
+ hasProblems() {
35
+ return this.coverageProblems.length > 0 || this.invalidTags.length > 0 || this.fileErrors.length > 0;
36
+ }
37
+ isFullyCovered() {
38
+ return this.coveredSpecCount === this.specCount;
39
+ }
40
+ }
41
+ function coverageProblemOf(problem) {
42
+ switch (problem.kind) {
43
+ case 'uncovered-spec':
44
+ return {
45
+ kind: 'uncovered-spec',
46
+ id: problem.specItem.id.value,
47
+ location: locationOf(problem.specItem.location),
48
+ text: problem.specItem.text,
49
+ };
50
+ case 'unknown-id': {
51
+ const [first, ...rest] = problem.testItems;
52
+ return {
53
+ kind: 'unknown-id',
54
+ id: problem.id.value,
55
+ testLocations: [locationOf(first.location), ...rest.map((testItem) => locationOf(testItem.location))],
56
+ };
57
+ }
58
+ case 'duplicate-id': {
59
+ const [first, second, ...rest] = problem.specItems;
60
+ return {
61
+ kind: 'duplicate-id',
62
+ id: problem.id.value,
63
+ specLocations: [locationOf(first.location), locationOf(second.location), ...rest.map((specItem) => locationOf(specItem.location))],
64
+ };
65
+ }
66
+ }
67
+ }
68
+ function invalidTagOf(invalidTag) {
69
+ const location = locationOf(invalidTag.location);
70
+ switch (invalidTag.kind) {
71
+ case 'id-rule-violation': {
72
+ const [first, ...rest] = invalidTag.invalidCandidates;
73
+ return { kind: 'id-rule-violation', location, invalidCandidates: [invalidIdCandidateOf(first), ...rest.map(invalidIdCandidateOf)] };
74
+ }
75
+ case 'missing-id':
76
+ return { kind: 'missing-id', location };
77
+ case 'multiple-ids': {
78
+ const [first, second, ...rest] = invalidTag.ids;
79
+ return { kind: 'multiple-ids', location, ids: [first.value, second.value, ...rest.map((id) => id.value)] };
80
+ }
81
+ }
82
+ }
83
+ function invalidIdCandidateOf(candidate) {
84
+ switch (candidate.kind) {
85
+ case 'invalid-whitespace':
86
+ return { kind: 'invalid-whitespace', value: candidate.value };
87
+ case 'invalid-char':
88
+ return { kind: 'invalid-char', value: candidate.value, firstInvalidChar: candidate.firstInvalidChar };
89
+ }
90
+ }
91
+ function locationOf(location) {
92
+ return { path: location.path, line: location.line };
93
+ }
@@ -0,0 +1,23 @@
1
+ import type { FileFinder } from '../file/file.finder.js';
2
+ import type { FileReader } from '../file/file.reader.js';
3
+ import type { SpecDocumentParser } from '../spec/spec-document.parser.js';
4
+ import type { TestFileParser } from '../test/test-file.parser.js';
5
+ import { CoverageCheckResult, type UnmatchedPatterns } from './coverage-check.result.js';
6
+ export type CoverageCheckInput = {
7
+ /** Glob patterns of the spec documents. */
8
+ readonly specPatterns: readonly [string, ...string[]];
9
+ /** Glob patterns of the test files. */
10
+ readonly testPatterns: readonly [string, ...string[]];
11
+ };
12
+ export declare class CoverageCheckUsecase {
13
+ private readonly fileFinder;
14
+ private readonly fileReader;
15
+ private readonly specDocumentParser;
16
+ private readonly testFileParser;
17
+ constructor(fileFinder: FileFinder, fileReader: FileReader, specDocumentParser: SpecDocumentParser, testFileParser: TestFileParser);
18
+ run(input: CoverageCheckInput): Promise<CoverageCheckResult | UnmatchedPatterns>;
19
+ private findFiles;
20
+ private readFiles;
21
+ private parseSpecDocument;
22
+ private parseTestFile;
23
+ }
@@ -0,0 +1,76 @@
1
+ import { Coverage } from '../../domain/coverage/coverage.js';
2
+ import { SpecDocument } from '../../domain/spec/spec-document.js';
3
+ import { TestFile } from '../../domain/test/test-file.js';
4
+ import { isNonEmpty } from '../../lib/array-length.js';
5
+ import { CoverageCheckResult } from './coverage-check.result.js';
6
+ export class CoverageCheckUsecase {
7
+ fileFinder;
8
+ fileReader;
9
+ specDocumentParser;
10
+ testFileParser;
11
+ constructor(fileFinder, fileReader, specDocumentParser, testFileParser) {
12
+ this.fileFinder = fileFinder;
13
+ this.fileReader = fileReader;
14
+ this.specDocumentParser = specDocumentParser;
15
+ this.testFileParser = testFileParser;
16
+ }
17
+ async run(input) {
18
+ const [specPaths, testPaths] = await Promise.all([this.findFiles(input.specPatterns), this.findFiles(input.testPatterns)]);
19
+ const unmatchedPatterns = [
20
+ ...specPaths.unmatchedPatterns.map((pattern) => ({ side: 'spec', pattern })),
21
+ ...testPaths.unmatchedPatterns.map((pattern) => ({ side: 'test', pattern })),
22
+ ];
23
+ if (isNonEmpty(unmatchedPatterns)) {
24
+ return { kind: 'unmatched-patterns', patterns: unmatchedPatterns };
25
+ }
26
+ const [specFiles, testFiles] = await Promise.all([this.readFiles(specPaths.paths), this.readFiles(testPaths.paths)]);
27
+ const specs = collect(specFiles, (path, content) => this.parseSpecDocument(path, content));
28
+ const tests = collect(testFiles, (path, content) => this.parseTestFile(path, content));
29
+ return CoverageCheckResult.fromDomain({
30
+ coverage: Coverage.of({ specItems: specs.items, testItems: tests.items }),
31
+ invalidTags: [...specs.invalidTags, ...tests.invalidTags],
32
+ fileErrors: [...specs.fileErrors, ...tests.fileErrors],
33
+ });
34
+ }
35
+ async findFiles(patterns) {
36
+ const uniquePatterns = [...new Set(patterns)];
37
+ const found = await Promise.all(uniquePatterns.map(async (pattern) => ({ pattern, paths: await this.fileFinder.find([pattern]) })));
38
+ const unmatchedPatterns = found.filter(({ paths }) => paths.length === 0).map(({ pattern }) => pattern);
39
+ const uniquePaths = [...new Set(found.flatMap(({ paths }) => paths))];
40
+ return { paths: uniquePaths.sort(), unmatchedPatterns };
41
+ }
42
+ async readFiles(pathsInCodeUnitOrder) {
43
+ return Promise.all(pathsInCodeUnitOrder.map(async (path) => ({ path, result: await this.fileReader.read(path) })));
44
+ }
45
+ parseSpecDocument(path, content) {
46
+ const document = SpecDocument.of(path, this.specDocumentParser.parse(content));
47
+ return { kind: 'parsed', items: document.specItems, invalidTags: document.invalidTags };
48
+ }
49
+ parseTestFile(path, content) {
50
+ const result = this.testFileParser.parse(path, content);
51
+ switch (result.kind) {
52
+ case 'parsed': {
53
+ const testFile = TestFile.of(path, result.sites);
54
+ return { kind: 'parsed', items: testFile.items, invalidTags: testFile.invalidTags };
55
+ }
56
+ case 'unsupported-extension':
57
+ return { kind: 'unsupported-extension', path, extension: result.extension, supportedExtensions: result.supportedExtensions };
58
+ case 'syntax-error':
59
+ return { kind: 'syntax-error', path, messages: result.messages };
60
+ }
61
+ }
62
+ }
63
+ function collect(files, parse) {
64
+ const collected = { items: [], invalidTags: [], fileErrors: [] };
65
+ for (const { path, result } of files) {
66
+ const outcome = result.kind === 'read-error' ? { kind: 'read-error', path, message: result.message } : parse(path, result.content);
67
+ if (outcome.kind === 'parsed') {
68
+ collected.items.push(...outcome.items);
69
+ collected.invalidTags.push(...outcome.invalidTags);
70
+ }
71
+ else {
72
+ collected.fileErrors.push(outcome);
73
+ }
74
+ }
75
+ return collected;
76
+ }
@@ -0,0 +1,12 @@
1
+ export interface FileFinder {
2
+ /**
3
+ * Finds the files that match any of the glob patterns.
4
+ *
5
+ * @param patterns - Glob patterns relative to the root directory of the finder.
6
+ * @returns Paths relative to the root directory, with `/` as the separator.
7
+ * Files under a `node_modules` or `.git` directory are left out.
8
+ * The order is not specified, and a path may appear more than once.
9
+ * Empty when no file matches or the root directory does not exist.
10
+ */
11
+ find(patterns: readonly [string, ...string[]]): Promise<string[]>;
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ /** The whole file was read. */
2
+ export type FileRead = {
3
+ readonly kind: 'read';
4
+ /** Full text of the file, decoded as UTF-8. */
5
+ readonly content: string;
6
+ };
7
+ /** The file could not be read, for example because permission is missing or the file was removed. */
8
+ export type FileReadError = {
9
+ readonly kind: 'read-error';
10
+ /** Error message reported by the file system. */
11
+ readonly message: string;
12
+ };
13
+ export type FileReadResult = FileRead | FileReadError;
14
+ export interface FileReader {
15
+ /**
16
+ * Reads one file as text.
17
+ *
18
+ * @param path - Path relative to the root directory of the reader, as returned by `FileFinder`.
19
+ */
20
+ read(path: string): Promise<FileReadResult>;
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { SpecTagSite } from '../../domain/spec/spec-tag-site.boundary.js';
2
+ export interface SpecDocumentParser {
3
+ /**
4
+ * Finds the tag sites of the list items in one spec document.
5
+ *
6
+ * @param content - Full text of the spec document.
7
+ * @returns The tag sites in the order they appear in the document. Empty when the document has none.
8
+ * The text of each tag site is the spec sentence: the first paragraph of the list item without the comment, on one line.
9
+ */
10
+ parse(content: string): SpecTagSite[];
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import type { TagSite } from '../../domain/shared/tag-site.boundary.js';
2
+ /** The whole file was read. */
3
+ export type TestFileParsed = {
4
+ readonly kind: 'parsed';
5
+ /** The tag sites of the test calls in the order they appear in the file. Empty when the file has none. */
6
+ readonly sites: readonly TagSite[];
7
+ };
8
+ /** The language of the file could not be determined from the extension of `path`. */
9
+ export type UnsupportedExtension = {
10
+ readonly kind: 'unsupported-extension';
11
+ /** The extension including the leading dot, for example `.py`. Empty when `path` has no extension. */
12
+ readonly extension: string;
13
+ /** The extensions the parser supports, including the leading dot, for example `.ts`. */
14
+ readonly supportedExtensions: readonly [string, ...string[]];
15
+ };
16
+ /**
17
+ * The file has a syntax error.
18
+ *
19
+ * @remarks
20
+ * A test runner cannot load such a file either, so none of its tests run.
21
+ * For that reason no test items are returned, even when part of the file could be read.
22
+ */
23
+ export type TestFileSyntaxError = {
24
+ readonly kind: 'syntax-error';
25
+ /** Error messages reported by the parser. */
26
+ readonly messages: readonly [string, ...string[]];
27
+ };
28
+ export type TestFileParseResult = TestFileParsed | UnsupportedExtension | TestFileSyntaxError;
29
+ export interface TestFileParser {
30
+ /**
31
+ * Finds the tag sites of the test calls in one test file.
32
+ *
33
+ * @param path - Path of the test file. Its extension selects the language.
34
+ * @param content - Full text of the test file.
35
+ */
36
+ parse(path: string, content: string): TestFileParseResult;
37
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};