@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/dist/bin.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { CoverageCheckUsecase } from './application/coverage/coverage-check.usecase.js';
3
+ import { FileFsFinder } from './infrastructure/filesystem/file.fs.finder.js';
4
+ import { FileFsReader } from './infrastructure/filesystem/file.fs.reader.js';
5
+ import { SpecDocumentMarkdownParser } from './infrastructure/markdown/spec-document.markdown.parser.js';
6
+ import { TestFileTypescriptParser } from './infrastructure/typescript/test-file.typescript.parser.js';
7
+ import { CoverageCheckCommand } from './presentation/cli/coverage-check.command.js';
8
+ import { CoverageCheckTextFormatter } from './presentation/coverage-check.text.formatter.js';
9
+ function hasColors(stream) {
10
+ if (stream.isTTY)
11
+ return stream.hasColors();
12
+ const forceColor = process.env['FORCE_COLOR'];
13
+ return forceColor !== undefined && forceColor !== '0' && forceColor !== 'false';
14
+ }
15
+ const rootDir = process.cwd();
16
+ const command = new CoverageCheckCommand(new CoverageCheckUsecase(new FileFsFinder(rootDir), new FileFsReader(rootDir), new SpecDocumentMarkdownParser(), new TestFileTypescriptParser()), new CoverageCheckTextFormatter({ stdout: hasColors(process.stdout), stderr: hasColors(process.stderr) }));
17
+ const output = await command.run(process.argv.slice(2));
18
+ process.stdout.write(output.stdout);
19
+ process.stderr.write(output.stderr);
20
+ process.exitCode = output.exitCode;
@@ -0,0 +1,24 @@
1
+ import type { SpecId } from '../spec/spec-id.js';
2
+ import type { SpecItem } from '../spec/spec-item.js';
3
+ import type { TestItem } from '../test/test-item.js';
4
+ /** A spec item whose id no other spec item has, and that no test item points to. */
5
+ export type UncoveredSpec = {
6
+ readonly kind: 'uncovered-spec';
7
+ readonly specItem: SpecItem;
8
+ };
9
+ /** An id that test items point to, but that no spec item has. */
10
+ export type UnknownId = {
11
+ readonly kind: 'unknown-id';
12
+ readonly id: SpecId;
13
+ /** The test items that point to the id, in the order they were given. */
14
+ readonly testItems: readonly [TestItem, ...TestItem[]];
15
+ };
16
+ /** An id that two or more spec items have. Coverage does not judge whether these spec items are covered. */
17
+ export type DuplicateId = {
18
+ readonly kind: 'duplicate-id';
19
+ readonly id: SpecId;
20
+ /** The spec items that have the id, in the order they were given. */
21
+ readonly specItems: readonly [SpecItem, SpecItem, ...SpecItem[]];
22
+ };
23
+ /** A place where the link between spec items and test items is broken. */
24
+ export type CoverageProblem = UncoveredSpec | UnknownId | DuplicateId;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { CoverageProblem } from './coverage-problem.js';
2
+ import type { SpecItem } from '../spec/spec-item.js';
3
+ import type { TestItem } from '../test/test-item.js';
4
+ export declare class Coverage {
5
+ readonly problems: readonly CoverageProblem[];
6
+ readonly specCount: number;
7
+ readonly coveredSpecCount: number;
8
+ private constructor();
9
+ static of({ specItems, testItems }: {
10
+ readonly specItems: readonly SpecItem[];
11
+ readonly testItems: readonly TestItem[];
12
+ }): Coverage;
13
+ private static judgeSpecGroup;
14
+ private static unknownIdsOf;
15
+ }
@@ -0,0 +1,46 @@
1
+ import { hasTwoOrMore } from '../../lib/array-length.js';
2
+ export class Coverage {
3
+ problems;
4
+ specCount;
5
+ coveredSpecCount;
6
+ constructor(problems, specCount, coveredSpecCount) {
7
+ this.problems = problems;
8
+ this.specCount = specCount;
9
+ this.coveredSpecCount = coveredSpecCount;
10
+ }
11
+ static of({ specItems, testItems }) {
12
+ const specGroups = groupById(specItems, (specItem) => [specItem.id]);
13
+ const testGroups = groupById(testItems, (testItem) => testItem.ids.values);
14
+ const specJudgments = [...specGroups.values()].map((specGroup) => Coverage.judgeSpecGroup(specGroup, testGroups));
15
+ const unknownIds = Coverage.unknownIdsOf(testGroups, specGroups);
16
+ return new Coverage([...specJudgments.filter(isProblem), ...unknownIds], specItems.length, specJudgments.filter((judgment) => judgment.kind === 'covered').length);
17
+ }
18
+ static judgeSpecGroup({ id, items }, testGroups) {
19
+ if (hasTwoOrMore(items))
20
+ return { kind: 'duplicate-id', id, specItems: items };
21
+ if (testGroups.has(id.key))
22
+ return { kind: 'covered' };
23
+ return { kind: 'uncovered-spec', specItem: items[0] };
24
+ }
25
+ static unknownIdsOf(testGroups, specGroups) {
26
+ return [...testGroups.values()]
27
+ .filter(({ id }) => !specGroups.has(id.key))
28
+ .map(({ id, items }) => ({ kind: 'unknown-id', id, testItems: items }));
29
+ }
30
+ }
31
+ function isProblem(judgment) {
32
+ return judgment.kind !== 'covered';
33
+ }
34
+ function groupById(items, idsOf) {
35
+ const groups = new Map();
36
+ for (const item of items) {
37
+ for (const id of idsOf(item)) {
38
+ const group = groups.get(id.key);
39
+ if (group === undefined)
40
+ groups.set(id.key, { id, items: [item] });
41
+ else
42
+ group.items.push(item);
43
+ }
44
+ }
45
+ return groups;
46
+ }
@@ -0,0 +1,22 @@
1
+ /** The comment body does not start with "nocap:". */
2
+ export type NotTag = {
3
+ readonly kind: 'not-tag';
4
+ };
5
+ /** "nocap:" has no id after it, or two commas have nothing between them. */
6
+ export type MissingId = {
7
+ readonly kind: 'missing-id';
8
+ };
9
+ /** The body is a tag and splits into one or more id candidates. */
10
+ type CandidatesFound = {
11
+ readonly kind: 'candidates';
12
+ readonly candidates: IdCandidates;
13
+ };
14
+ export declare class IdCandidates {
15
+ readonly values: readonly [string, ...string[]];
16
+ private constructor();
17
+ static parse(body: string): NotTag | MissingId | CandidatesFound;
18
+ private static textAfterPrefixOf;
19
+ private static partsOf;
20
+ private static withoutTrailingComma;
21
+ }
22
+ export {};
@@ -0,0 +1,34 @@
1
+ import { isNonEmpty } from '../../lib/array-length.js';
2
+ const PREFIX = 'nocap:';
3
+ export class IdCandidates {
4
+ values;
5
+ constructor(values) {
6
+ this.values = values;
7
+ }
8
+ static parse(body) {
9
+ const textAfterPrefix = IdCandidates.textAfterPrefixOf(body);
10
+ if (textAfterPrefix === undefined) {
11
+ return { kind: 'not-tag' };
12
+ }
13
+ const parts = IdCandidates.partsOf(textAfterPrefix);
14
+ if (parts.includes('')) {
15
+ return { kind: 'missing-id' };
16
+ }
17
+ return { kind: 'candidates', candidates: new IdCandidates(parts) };
18
+ }
19
+ static textAfterPrefixOf(body) {
20
+ const trimmed = body.trim();
21
+ return trimmed.startsWith(PREFIX) ? trimmed.slice(PREFIX.length) : undefined;
22
+ }
23
+ static partsOf(textAfterPrefix) {
24
+ const parts = textAfterPrefix.split(',').map((part) => part.trim());
25
+ const kept = IdCandidates.withoutTrailingComma(parts);
26
+ if (!isNonEmpty(kept))
27
+ throw new Error('Splitting a tag body must give at least one part');
28
+ return kept;
29
+ }
30
+ static withoutTrailingComma(parts) {
31
+ const endsWithComma = parts.length > 1 && parts.at(-1) === '';
32
+ return endsWithComma ? parts.slice(0, -1) : parts;
33
+ }
34
+ }
@@ -0,0 +1,8 @@
1
+ export declare class Location {
2
+ readonly path: string;
3
+ readonly line: number;
4
+ private constructor();
5
+ static of(path: string, line: number): Location;
6
+ private static assertPathNotEmpty;
7
+ private static assertLineIsPositiveInteger;
8
+ }
@@ -0,0 +1,22 @@
1
+ export class Location {
2
+ path;
3
+ line;
4
+ constructor(path, line) {
5
+ this.path = path;
6
+ this.line = line;
7
+ }
8
+ static of(path, line) {
9
+ Location.assertPathNotEmpty(path);
10
+ Location.assertLineIsPositiveInteger(line);
11
+ return new Location(path, line);
12
+ }
13
+ static assertPathNotEmpty(path) {
14
+ if (path === '')
15
+ throw new Error('Location path must not be empty');
16
+ }
17
+ static assertLineIsPositiveInteger(line) {
18
+ const isPositiveInteger = Number.isInteger(line) && line >= 1;
19
+ if (!isPositiveInteger)
20
+ throw new Error(`Location line must be an integer of 1 or more, got ${line}`);
21
+ }
22
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * An item that has a comment at its tag position. The comment body is not checked yet.
3
+ *
4
+ * @remarks
5
+ * The adapter of each format decides the tag position with the syntax of that format.
6
+ * In a spec document it is the end of the first paragraph of a list item.
7
+ * In a test file it is the line just above a test call, with no code before the comment on that line.
8
+ * An item without such a comment is not a tag site.
9
+ * The adapter does not check the body: a comment such as `TODO` still makes a tag site.
10
+ * Whether the body is a tag is decided by `Tag.parse`.
11
+ */
12
+ export type TagSite = {
13
+ /** 1-based line where the item starts. */
14
+ readonly line: number;
15
+ readonly comment: {
16
+ /** 1-based line where the comment starts. */
17
+ readonly line: number;
18
+ /** Text between the comment delimiters, for example between `<!--` and `-->`. Whitespace is kept as written. */
19
+ readonly body: string;
20
+ };
21
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import { SpecItem, type SpecInvalidTag } from './spec-item.js';
2
+ import type { SpecTagSite } from './spec-tag-site.boundary.js';
3
+ export declare class SpecDocument {
4
+ readonly specItems: readonly SpecItem[];
5
+ readonly invalidTags: readonly SpecInvalidTag[];
6
+ private constructor();
7
+ static of(path: string, sites: readonly SpecTagSite[]): SpecDocument;
8
+ }
@@ -0,0 +1,26 @@
1
+ import { SpecItem } from './spec-item.js';
2
+ export class SpecDocument {
3
+ specItems;
4
+ invalidTags;
5
+ constructor(specItems, invalidTags) {
6
+ this.specItems = specItems;
7
+ this.invalidTags = invalidTags;
8
+ }
9
+ static of(path, sites) {
10
+ const specItems = [];
11
+ const invalidTags = [];
12
+ for (const site of sites) {
13
+ const result = SpecItem.parse(path, site);
14
+ switch (result.kind) {
15
+ case 'not-tag':
16
+ break;
17
+ case 'valid':
18
+ specItems.push(result.item);
19
+ break;
20
+ default:
21
+ invalidTags.push(result);
22
+ }
23
+ }
24
+ return new SpecDocument(specItems, invalidTags);
25
+ }
26
+ }
@@ -0,0 +1,32 @@
1
+ /** The candidate passed the id rule. */
2
+ type ValidSpecId = {
3
+ readonly kind: 'valid';
4
+ readonly id: SpecId;
5
+ };
6
+ /**
7
+ * An id candidate that broke the id rule, with the reason taken from the first character the rule does not allow.
8
+ *
9
+ * @remarks
10
+ * `invalid-whitespace`: the first such character is a whitespace. Ids written without a comma between them give this reason.
11
+ * `invalid-char`: the first such character is any other character.
12
+ */
13
+ export type InvalidIdCandidate = {
14
+ readonly kind: 'invalid-whitespace';
15
+ readonly value: string;
16
+ } | {
17
+ readonly kind: 'invalid-char';
18
+ readonly value: string;
19
+ /** The first character in the candidate that the id rule does not allow. */
20
+ readonly firstInvalidChar: string;
21
+ };
22
+ export declare class SpecId {
23
+ readonly value: string;
24
+ private constructor();
25
+ static parse(candidate: string): ValidSpecId | InvalidIdCandidate;
26
+ get key(): string;
27
+ equals(other: SpecId): boolean;
28
+ private static assertNotEmpty;
29
+ private static invalidCandidateOf;
30
+ private static firstInvalidCharOf;
31
+ }
32
+ export {};
@@ -0,0 +1,36 @@
1
+ /** One character that the id rule allows: A-Z, a-z, 0-9, "-" or "_". */
2
+ const ID_CHAR = /^[A-Za-z0-9_-]$/u;
3
+ const WHITESPACE = /^\s$/u;
4
+ export class SpecId {
5
+ value;
6
+ constructor(value) {
7
+ this.value = value;
8
+ }
9
+ static parse(candidate) {
10
+ SpecId.assertNotEmpty(candidate);
11
+ const firstInvalidChar = SpecId.firstInvalidCharOf(candidate);
12
+ if (firstInvalidChar) {
13
+ return SpecId.invalidCandidateOf(candidate, firstInvalidChar);
14
+ }
15
+ return { kind: 'valid', id: new SpecId(candidate) };
16
+ }
17
+ get key() {
18
+ return this.value;
19
+ }
20
+ equals(other) {
21
+ return this.key === other.key;
22
+ }
23
+ static assertNotEmpty(candidate) {
24
+ if (candidate === '')
25
+ throw new Error('An id candidate must not be empty');
26
+ }
27
+ static invalidCandidateOf(candidate, firstInvalidChar) {
28
+ return WHITESPACE.test(firstInvalidChar)
29
+ ? { kind: 'invalid-whitespace', value: candidate }
30
+ : { kind: 'invalid-char', value: candidate, firstInvalidChar };
31
+ }
32
+ static firstInvalidCharOf(candidate) {
33
+ // oxlint-disable-next-line typescript/no-misused-spread -- an id is checked one code point at a time
34
+ return [...candidate].find((char) => !ID_CHAR.test(char));
35
+ }
36
+ }
@@ -0,0 +1,8 @@
1
+ import type { SpecId } from './spec-id.js';
2
+ export declare class SpecIds {
3
+ readonly values: readonly [SpecId, ...SpecId[]];
4
+ private constructor();
5
+ static of(ids: readonly [SpecId, ...SpecId[]]): SpecIds;
6
+ private add;
7
+ private has;
8
+ }
@@ -0,0 +1,18 @@
1
+ export class SpecIds {
2
+ values;
3
+ constructor(values) {
4
+ this.values = values;
5
+ }
6
+ static of(ids) {
7
+ const [first, ...others] = ids;
8
+ return others.reduce((specIds, id) => specIds.add(id), new SpecIds([first]));
9
+ }
10
+ add(id) {
11
+ if (this.has(id))
12
+ return this;
13
+ return new SpecIds([...this.values, id]);
14
+ }
15
+ has(id) {
16
+ return this.values.some((kept) => kept.equals(id));
17
+ }
18
+ }
@@ -0,0 +1,30 @@
1
+ import type { NotTag } from '../shared/id-candidates.js';
2
+ import { Location } from '../shared/location.js';
3
+ import type { SpecId } from './spec-id.js';
4
+ import type { SpecTagSite } from './spec-tag-site.boundary.js';
5
+ import { type InvalidTagReason } from './tag.js';
6
+ /** A spec tag has two or more ids. Only spec tags have this reason. */
7
+ type MultipleIds = {
8
+ readonly kind: 'multiple-ids';
9
+ readonly ids: readonly [SpecId, SpecId, ...SpecId[]];
10
+ };
11
+ /** Why a comment body that starts with "nocap:" did not become a spec tag. */
12
+ type SpecInvalidTagReason = InvalidTagReason | MultipleIds;
13
+ /** A spec tag that broke a rule, at the line where the tag starts. */
14
+ export type SpecInvalidTag = SpecInvalidTagReason & {
15
+ readonly location: Location;
16
+ };
17
+ /** The comment of the tag site is a valid spec tag, so the list item became a spec item. */
18
+ type ValidSpecItem = {
19
+ readonly kind: 'valid';
20
+ readonly item: SpecItem;
21
+ };
22
+ export declare class SpecItem {
23
+ readonly id: SpecId;
24
+ readonly location: Location;
25
+ readonly text: string;
26
+ private constructor();
27
+ static parse(path: string, site: SpecTagSite): NotTag | SpecInvalidTag | ValidSpecItem;
28
+ private static fromTag;
29
+ }
30
+ export {};
@@ -0,0 +1,34 @@
1
+ import { Location } from '../shared/location.js';
2
+ import { Tag } from './tag.js';
3
+ import { hasTwoOrMore } from '../../lib/array-length.js';
4
+ export class SpecItem {
5
+ id;
6
+ location;
7
+ text;
8
+ constructor(id, location, text) {
9
+ this.id = id;
10
+ this.location = location;
11
+ this.text = text;
12
+ }
13
+ static parse(path, site) {
14
+ const result = Tag.parse(site.comment.body);
15
+ switch (result.kind) {
16
+ case 'not-tag':
17
+ return result;
18
+ case 'missing-id':
19
+ case 'id-rule-violation':
20
+ return invalid(result, path, site.comment);
21
+ case 'valid':
22
+ return SpecItem.fromTag(result.tag, path, site);
23
+ }
24
+ }
25
+ static fromTag(tag, path, { line, text, comment }) {
26
+ if (hasTwoOrMore(tag.ids)) {
27
+ return invalid({ kind: 'multiple-ids', ids: tag.ids }, path, comment);
28
+ }
29
+ return { kind: 'valid', item: new SpecItem(tag.ids[0], Location.of(path, line), text) };
30
+ }
31
+ }
32
+ function invalid(reason, path, comment) {
33
+ return { ...reason, location: Location.of(path, comment.line) };
34
+ }
@@ -0,0 +1,5 @@
1
+ import type { TagSite } from '../shared/tag-site.boundary.js';
2
+ /** The tag site of a list item. `text` is the spec sentence of the list item. */
3
+ export type SpecTagSite = TagSite & {
4
+ readonly text: string;
5
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { type MissingId, type NotTag } from '../shared/id-candidates.js';
2
+ import { type InvalidIdCandidate, SpecId } from './spec-id.js';
3
+ /** One or more id candidates of a tag broke the id rule. Each one appears once. */
4
+ type IdRuleViolation = {
5
+ readonly kind: 'id-rule-violation';
6
+ readonly invalidCandidates: readonly [InvalidIdCandidate, ...InvalidIdCandidate[]];
7
+ };
8
+ /** Why a comment body that starts with "nocap:" did not become a tag. */
9
+ export type InvalidTagReason = MissingId | IdRuleViolation;
10
+ /** The body is a tag whose ids all passed the id rule. */
11
+ type ValidTag = {
12
+ readonly kind: 'valid';
13
+ readonly tag: Tag;
14
+ };
15
+ export declare class Tag {
16
+ readonly ids: readonly [SpecId, ...SpecId[]];
17
+ private constructor();
18
+ static parse(body: string): NotTag | InvalidTagReason | ValidTag;
19
+ private static fromCandidates;
20
+ }
21
+ export {};
@@ -0,0 +1,34 @@
1
+ import { isNonEmpty } from '../../lib/array-length.js';
2
+ import { IdCandidates } from '../shared/id-candidates.js';
3
+ import { SpecId } from './spec-id.js';
4
+ export class Tag {
5
+ ids;
6
+ constructor(ids) {
7
+ this.ids = ids;
8
+ }
9
+ static parse(body) {
10
+ const result = IdCandidates.parse(body);
11
+ switch (result.kind) {
12
+ case 'not-tag':
13
+ case 'missing-id':
14
+ return result;
15
+ case 'candidates':
16
+ return Tag.fromCandidates(result.candidates);
17
+ }
18
+ }
19
+ static fromCandidates(candidates) {
20
+ const results = candidates.values.map((candidate) => SpecId.parse(candidate));
21
+ const invalidCandidates = withoutDuplicates(results.filter((result) => result.kind !== 'valid'));
22
+ if (isNonEmpty(invalidCandidates)) {
23
+ return { kind: 'id-rule-violation', invalidCandidates };
24
+ }
25
+ const ids = results.flatMap((result) => (result.kind === 'valid' ? [result.id] : []));
26
+ if (!isNonEmpty(ids)) {
27
+ throw new Error('Id candidates with no invalid one must give at least one id');
28
+ }
29
+ return { kind: 'valid', tag: new Tag(ids) };
30
+ }
31
+ }
32
+ function withoutDuplicates(invalidCandidates) {
33
+ return invalidCandidates.filter((invalid, index) => invalidCandidates.findIndex((other) => other.value === invalid.value) === index);
34
+ }
@@ -0,0 +1,8 @@
1
+ import type { TagSite } from '../shared/tag-site.boundary.js';
2
+ import { TestItem, type TestInvalidTag } from './test-item.js';
3
+ export declare class TestFile {
4
+ readonly items: readonly TestItem[];
5
+ readonly invalidTags: readonly TestInvalidTag[];
6
+ private constructor();
7
+ static of(path: string, sites: readonly TagSite[]): TestFile;
8
+ }
@@ -0,0 +1,26 @@
1
+ import { TestItem } from './test-item.js';
2
+ export class TestFile {
3
+ items;
4
+ invalidTags;
5
+ constructor(items, invalidTags) {
6
+ this.items = items;
7
+ this.invalidTags = invalidTags;
8
+ }
9
+ static of(path, sites) {
10
+ const items = [];
11
+ const invalidTags = [];
12
+ for (const site of sites) {
13
+ const result = TestItem.parse(path, site);
14
+ switch (result.kind) {
15
+ case 'not-tag':
16
+ break;
17
+ case 'valid':
18
+ items.push(result.item);
19
+ break;
20
+ default:
21
+ invalidTags.push(result);
22
+ }
23
+ }
24
+ return new TestFile(items, invalidTags);
25
+ }
26
+ }
@@ -0,0 +1,22 @@
1
+ import type { NotTag } from '../shared/id-candidates.js';
2
+ import { Location } from '../shared/location.js';
3
+ import type { TagSite } from '../shared/tag-site.boundary.js';
4
+ import { SpecIds } from '../spec/spec-ids.js';
5
+ import { type InvalidTagReason } from '../spec/tag.js';
6
+ /** A test tag that broke a rule, at the line of the tag. */
7
+ export type TestInvalidTag = InvalidTagReason & {
8
+ readonly location: Location;
9
+ };
10
+ /** The comment of the tag site is a valid test tag, so the test call became a test item. */
11
+ type ValidTestItem = {
12
+ readonly kind: 'valid';
13
+ readonly item: TestItem;
14
+ };
15
+ export declare class TestItem {
16
+ readonly ids: SpecIds;
17
+ readonly location: Location;
18
+ private constructor();
19
+ static parse(path: string, site: TagSite): NotTag | TestInvalidTag | ValidTestItem;
20
+ private static fromTag;
21
+ }
22
+ export {};
@@ -0,0 +1,29 @@
1
+ import { Location } from '../shared/location.js';
2
+ import { SpecIds } from '../spec/spec-ids.js';
3
+ import { Tag } from '../spec/tag.js';
4
+ export class TestItem {
5
+ ids;
6
+ location;
7
+ constructor(ids, location) {
8
+ this.ids = ids;
9
+ this.location = location;
10
+ }
11
+ static parse(path, site) {
12
+ const result = Tag.parse(site.comment.body);
13
+ switch (result.kind) {
14
+ case 'not-tag':
15
+ return result;
16
+ case 'missing-id':
17
+ case 'id-rule-violation':
18
+ return invalid(result, path, site.comment);
19
+ case 'valid':
20
+ return TestItem.fromTag(result.tag, path, site);
21
+ }
22
+ }
23
+ static fromTag(tag, path, { line }) {
24
+ return { kind: 'valid', item: new TestItem(SpecIds.of(tag.ids), Location.of(path, line)) };
25
+ }
26
+ }
27
+ function invalid(reason, path, comment) {
28
+ return { ...reason, location: Location.of(path, comment.line) };
29
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { FileFinder } from '../../application/file/file.finder.js';
2
+ export declare class FileFsFinder implements FileFinder {
3
+ private readonly rootDir;
4
+ constructor(rootDir: string);
5
+ find(patterns: readonly [string, ...string[]]): Promise<string[]>;
6
+ }
@@ -0,0 +1,17 @@
1
+ import { glob } from 'tinyglobby';
2
+ const EXCLUDED_DIRECTORIES = ['**/node_modules/**', '**/.git/**'];
3
+ export class FileFsFinder {
4
+ rootDir;
5
+ constructor(rootDir) {
6
+ this.rootDir = rootDir;
7
+ }
8
+ async find(patterns) {
9
+ return glob([...patterns], {
10
+ cwd: this.rootDir,
11
+ ignore: EXCLUDED_DIRECTORIES,
12
+ dot: true,
13
+ onlyFiles: true,
14
+ expandDirectories: false,
15
+ });
16
+ }
17
+ }
@@ -0,0 +1,6 @@
1
+ import type { FileReader, FileReadResult } from '../../application/file/file.reader.js';
2
+ export declare class FileFsReader implements FileReader {
3
+ private readonly rootDir;
4
+ constructor(rootDir: string);
5
+ read(path: string): Promise<FileReadResult>;
6
+ }
@@ -0,0 +1,20 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export class FileFsReader {
4
+ rootDir;
5
+ constructor(rootDir) {
6
+ this.rootDir = rootDir;
7
+ }
8
+ async read(path) {
9
+ try {
10
+ const content = await readFile(join(this.rootDir, path), 'utf8');
11
+ return { kind: 'read', content };
12
+ }
13
+ catch (error) {
14
+ return { kind: 'read-error', message: messageOf(error) };
15
+ }
16
+ }
17
+ }
18
+ function messageOf(error) {
19
+ return error instanceof Error ? error.message : String(error);
20
+ }