@application-kit/acceptance 0.0.0-stage → 0.1.1
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/CHANGELOG.md +13 -0
- package/dist/cjs/bin/application-scenarios.d.ts +2 -0
- package/dist/cjs/bin/application-scenarios.js +15 -0
- package/dist/cjs/bin/index.d.ts +1 -0
- package/dist/cjs/bin/index.js +17 -0
- package/dist/cjs/cadence/index.d.ts +1 -0
- package/dist/cjs/cadence/index.js +17 -0
- package/dist/cjs/cadence/resolve-cadence.d.ts +8 -0
- package/dist/cjs/cadence/resolve-cadence.js +18 -0
- package/dist/cjs/environments/environment.interface.d.ts +5 -0
- package/dist/cjs/environments/environment.interface.js +2 -0
- package/dist/cjs/environments/index.d.ts +3 -0
- package/dist/cjs/environments/index.js +19 -0
- package/dist/cjs/environments/resolve-environment.d.ts +10 -0
- package/dist/cjs/environments/resolve-environment.js +20 -0
- package/dist/cjs/environments/secrets.d.ts +4 -0
- package/dist/cjs/environments/secrets.js +14 -0
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +18 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/scenarios/acceptance-class.d.ts +26 -0
- package/dist/cjs/scenarios/acceptance-class.js +100 -0
- package/dist/cjs/scenarios/board.d.ts +6 -0
- package/dist/cjs/scenarios/board.js +98 -0
- package/dist/cjs/scenarios/classes.d.ts +16 -0
- package/dist/cjs/scenarios/classes.js +48 -0
- package/dist/cjs/scenarios/classify.d.ts +35 -0
- package/dist/cjs/scenarios/classify.js +110 -0
- package/dist/cjs/scenarios/config.d.ts +17 -0
- package/dist/cjs/scenarios/config.js +67 -0
- package/dist/cjs/scenarios/coverage-data.d.ts +53 -0
- package/dist/cjs/scenarios/coverage-data.js +118 -0
- package/dist/cjs/scenarios/coverage-json.d.ts +2 -0
- package/dist/cjs/scenarios/coverage-json.js +94 -0
- package/dist/cjs/scenarios/coverage-open.d.ts +16 -0
- package/dist/cjs/scenarios/coverage-open.js +76 -0
- package/dist/cjs/scenarios/coverage-page.d.ts +2 -0
- package/dist/cjs/scenarios/coverage-page.js +108 -0
- package/dist/cjs/scenarios/coverage-removed.d.ts +13 -0
- package/dist/cjs/scenarios/coverage-removed.js +30 -0
- package/dist/cjs/scenarios/coverage.d.ts +26 -0
- package/dist/cjs/scenarios/coverage.js +91 -0
- package/dist/cjs/scenarios/dates.d.ts +8 -0
- package/dist/cjs/scenarios/dates.js +93 -0
- package/dist/cjs/scenarios/declared.d.ts +18 -0
- package/dist/cjs/scenarios/declared.js +69 -0
- package/dist/cjs/scenarios/features.d.ts +13 -0
- package/dist/cjs/scenarios/features.js +112 -0
- package/dist/cjs/scenarios/gherkin.d.ts +10 -0
- package/dist/cjs/scenarios/gherkin.js +75 -0
- package/dist/cjs/scenarios/git.d.ts +15 -0
- package/dist/cjs/scenarios/git.js +83 -0
- package/dist/cjs/scenarios/index.d.ts +27 -0
- package/dist/cjs/scenarios/index.js +43 -0
- package/dist/cjs/scenarios/inventory.d.ts +8 -0
- package/dist/cjs/scenarios/inventory.js +55 -0
- package/dist/cjs/scenarios/links.d.ts +4 -0
- package/dist/cjs/scenarios/links.js +99 -0
- package/dist/cjs/scenarios/lint-inputs.d.ts +12 -0
- package/dist/cjs/scenarios/lint-inputs.js +118 -0
- package/dist/cjs/scenarios/lint.d.ts +8 -0
- package/dist/cjs/scenarios/lint.js +94 -0
- package/dist/cjs/scenarios/projects.d.ts +21 -0
- package/dist/cjs/scenarios/projects.js +115 -0
- package/dist/cjs/scenarios/python-text.d.ts +11 -0
- package/dist/cjs/scenarios/python-text.js +95 -0
- package/dist/cjs/scenarios/remote.d.ts +6 -0
- package/dist/cjs/scenarios/remote.js +29 -0
- package/dist/cjs/scenarios/removed-list.d.ts +15 -0
- package/dist/cjs/scenarios/removed-list.js +62 -0
- package/dist/cjs/scenarios/report.d.ts +6 -0
- package/dist/cjs/scenarios/report.js +10 -0
- package/dist/cjs/scenarios/run.d.ts +10 -0
- package/dist/cjs/scenarios/run.js +145 -0
- package/dist/cjs/scenarios/task-links.d.ts +15 -0
- package/dist/cjs/scenarios/task-links.js +54 -0
- package/dist/esm/bin/application-scenarios.d.ts +2 -0
- package/dist/esm/bin/application-scenarios.js +10 -0
- package/dist/esm/bin/index.d.ts +1 -0
- package/dist/esm/bin/index.js +1 -0
- package/dist/esm/cadence/index.d.ts +1 -0
- package/dist/esm/cadence/index.js +1 -0
- package/dist/esm/cadence/resolve-cadence.d.ts +8 -0
- package/dist/esm/cadence/resolve-cadence.js +14 -0
- package/dist/esm/environments/environment.interface.d.ts +5 -0
- package/dist/esm/environments/environment.interface.js +1 -0
- package/dist/esm/environments/index.d.ts +3 -0
- package/dist/esm/environments/index.js +3 -0
- package/dist/esm/environments/resolve-environment.d.ts +10 -0
- package/dist/esm/environments/resolve-environment.js +16 -0
- package/dist/esm/environments/secrets.d.ts +4 -0
- package/dist/esm/environments/secrets.js +9 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/scenarios/acceptance-class.d.ts +26 -0
- package/dist/esm/scenarios/acceptance-class.js +93 -0
- package/dist/esm/scenarios/board.d.ts +6 -0
- package/dist/esm/scenarios/board.js +92 -0
- package/dist/esm/scenarios/classes.d.ts +16 -0
- package/dist/esm/scenarios/classes.js +42 -0
- package/dist/esm/scenarios/classify.d.ts +35 -0
- package/dist/esm/scenarios/classify.js +106 -0
- package/dist/esm/scenarios/config.d.ts +17 -0
- package/dist/esm/scenarios/config.js +58 -0
- package/dist/esm/scenarios/coverage-data.d.ts +53 -0
- package/dist/esm/scenarios/coverage-data.js +109 -0
- package/dist/esm/scenarios/coverage-json.d.ts +2 -0
- package/dist/esm/scenarios/coverage-json.js +90 -0
- package/dist/esm/scenarios/coverage-open.d.ts +16 -0
- package/dist/esm/scenarios/coverage-open.js +71 -0
- package/dist/esm/scenarios/coverage-page.d.ts +2 -0
- package/dist/esm/scenarios/coverage-page.js +104 -0
- package/dist/esm/scenarios/coverage-removed.d.ts +13 -0
- package/dist/esm/scenarios/coverage-removed.js +26 -0
- package/dist/esm/scenarios/coverage.d.ts +26 -0
- package/dist/esm/scenarios/coverage.js +84 -0
- package/dist/esm/scenarios/dates.d.ts +8 -0
- package/dist/esm/scenarios/dates.js +87 -0
- package/dist/esm/scenarios/declared.d.ts +18 -0
- package/dist/esm/scenarios/declared.js +62 -0
- package/dist/esm/scenarios/features.d.ts +13 -0
- package/dist/esm/scenarios/features.js +96 -0
- package/dist/esm/scenarios/gherkin.d.ts +10 -0
- package/dist/esm/scenarios/gherkin.js +68 -0
- package/dist/esm/scenarios/git.d.ts +15 -0
- package/dist/esm/scenarios/git.js +72 -0
- package/dist/esm/scenarios/index.d.ts +27 -0
- package/dist/esm/scenarios/index.js +27 -0
- package/dist/esm/scenarios/inventory.d.ts +8 -0
- package/dist/esm/scenarios/inventory.js +48 -0
- package/dist/esm/scenarios/links.d.ts +4 -0
- package/dist/esm/scenarios/links.js +95 -0
- package/dist/esm/scenarios/lint-inputs.d.ts +12 -0
- package/dist/esm/scenarios/lint-inputs.js +110 -0
- package/dist/esm/scenarios/lint.d.ts +8 -0
- package/dist/esm/scenarios/lint.js +89 -0
- package/dist/esm/scenarios/projects.d.ts +21 -0
- package/dist/esm/scenarios/projects.js +104 -0
- package/dist/esm/scenarios/python-text.d.ts +11 -0
- package/dist/esm/scenarios/python-text.js +81 -0
- package/dist/esm/scenarios/remote.d.ts +6 -0
- package/dist/esm/scenarios/remote.js +24 -0
- package/dist/esm/scenarios/removed-list.d.ts +15 -0
- package/dist/esm/scenarios/removed-list.js +57 -0
- package/dist/esm/scenarios/report.d.ts +6 -0
- package/dist/esm/scenarios/report.js +6 -0
- package/dist/esm/scenarios/run.d.ts +10 -0
- package/dist/esm/scenarios/run.js +141 -0
- package/dist/esm/scenarios/task-links.d.ts +15 -0
- package/dist/esm/scenarios/task-links.js +50 -0
- package/package.json +65 -3
- package/README.md +0 -3
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ConfigInterface } from './config.js';
|
|
2
|
+
import type { ProjectInterface } from './projects.js';
|
|
3
|
+
export interface DeclaredInputInterface {
|
|
4
|
+
readonly config: ConfigInterface;
|
|
5
|
+
readonly cwd: string;
|
|
6
|
+
readonly projects: string;
|
|
7
|
+
readonly revisions: ReadonlyMap<string, string>;
|
|
8
|
+
}
|
|
9
|
+
export interface DeclaredInterface {
|
|
10
|
+
readonly declared: readonly DeclaredProjectInterface[];
|
|
11
|
+
readonly notes: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
interface DeclaredProjectInterface {
|
|
14
|
+
readonly defect: string | undefined;
|
|
15
|
+
readonly project: ProjectInterface;
|
|
16
|
+
}
|
|
17
|
+
export declare const declaredProjects: (input: DeclaredInputInterface) => Promise<DeclaredInterface>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { acceptanceKey, isRecord, parseBoard } from './board.js';
|
|
3
|
+
import { ConfigError, isProjectCovered } from './config.js';
|
|
4
|
+
import { joinPath } from './features.js';
|
|
5
|
+
import { commonDirectory } from './git.js';
|
|
6
|
+
import { walkProjects } from './projects.js';
|
|
7
|
+
import { messageOf, pyRepr, pyText } from './python-text.js';
|
|
8
|
+
const repoFromKey = (value, projects) => {
|
|
9
|
+
const text = pyText(value).trim();
|
|
10
|
+
if (text.startsWith('projects/')) {
|
|
11
|
+
return joinPath(projects, text.slice('projects/'.length));
|
|
12
|
+
}
|
|
13
|
+
return path.isAbsolute(text) ? text : undefined;
|
|
14
|
+
};
|
|
15
|
+
const keyValue = (project) => {
|
|
16
|
+
if (project.board === undefined) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
let header;
|
|
20
|
+
try {
|
|
21
|
+
header = parseBoard(project.board);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw new ConfigError(`board проекта ${project.name} на ${project.revision} не читается: ${messageOf(error)}`);
|
|
25
|
+
}
|
|
26
|
+
return isRecord(header) ? header[acceptanceKey] : undefined;
|
|
27
|
+
};
|
|
28
|
+
const isSet = (value) => {
|
|
29
|
+
if (isRecord(value)) {
|
|
30
|
+
return Object.keys(value).length > 0;
|
|
31
|
+
}
|
|
32
|
+
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
|
33
|
+
};
|
|
34
|
+
const defectOf = (config, project, value) => {
|
|
35
|
+
const defect = `board проекта ${project.name} объявляет этот репозиторий тестов (${acceptanceKey}: ${pyText(value)}), а «id» `
|
|
36
|
+
+ 'конфига проект не покрывает — ссылки его story не распознаются';
|
|
37
|
+
return isProjectCovered(config, project.name) ? undefined : defect;
|
|
38
|
+
};
|
|
39
|
+
const isOwnRepo = async (cwd, own, repo) => {
|
|
40
|
+
const common = await commonDirectory(cwd, repo);
|
|
41
|
+
return own !== undefined && common === own;
|
|
42
|
+
};
|
|
43
|
+
const probe = async (input, own, project) => {
|
|
44
|
+
const value = keyValue(project);
|
|
45
|
+
if (!isSet(value)) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const repo = repoFromKey(value, input.projects);
|
|
49
|
+
if (repo === undefined) {
|
|
50
|
+
return `проект ${project.name} пропущен — ${acceptanceKey}: ${pyRepr(value)}, ждём projects/<репозиторий> или абсолютный путь`;
|
|
51
|
+
}
|
|
52
|
+
return await isOwnRepo(input.cwd, own, repo) ? { defect: defectOf(input.config, project, value), project } : undefined;
|
|
53
|
+
};
|
|
54
|
+
export const declaredProjects = async (input) => {
|
|
55
|
+
const own = await commonDirectory(input.cwd, '.');
|
|
56
|
+
const walk = await walkProjects(input);
|
|
57
|
+
const probes = await Promise.all(walk.projects.map(async (project) => probe(input, own, project)));
|
|
58
|
+
return {
|
|
59
|
+
declared: probes.filter((item) => typeof item === 'object'),
|
|
60
|
+
notes: [...walk.notes, ...probes.filter((item) => typeof item === 'string')],
|
|
61
|
+
};
|
|
62
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ConfigInterface } from './config.js';
|
|
2
|
+
import type { ScenarioInterface } from './gherkin.js';
|
|
3
|
+
export type FilesType = ReadonlyMap<string, string>;
|
|
4
|
+
export declare const joinPath: (directory: string, name: string) => string;
|
|
5
|
+
export declare const isFile: (cwd: string, file: string) => Promise<boolean>;
|
|
6
|
+
export declare const isDirectory: (target: string) => Promise<boolean>;
|
|
7
|
+
export declare const walkFiles: (cwd: string, directory: string, extension: string) => Promise<string[]>;
|
|
8
|
+
export declare const readText: (cwd: string, file: string) => Promise<string>;
|
|
9
|
+
export declare const worktreeFeatures: (cwd: string, config: ConfigInterface) => Promise<FilesType>;
|
|
10
|
+
export declare const baseFeatures: (cwd: string, config: ConfigInterface, base: string) => Promise<FilesType>;
|
|
11
|
+
export declare const allScenarios: (files: FilesType) => ScenarioInterface[];
|
|
12
|
+
export declare const scenarioIds: (config: ConfigInterface, files: FilesType) => Map<string, ScenarioInterface[]>;
|
|
13
|
+
export declare const countMark: (scenarios: readonly ScenarioInterface[], mark: string) => number;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { access, readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ConfigError } from './config.js';
|
|
4
|
+
import { parseFeature } from './gherkin.js';
|
|
5
|
+
import { currentRepo, git, runGit, stdoutText } from './git.js';
|
|
6
|
+
import { sortedText, universalNewlines } from './python-text.js';
|
|
7
|
+
export const joinPath = (directory, name) => {
|
|
8
|
+
const separator = directory.endsWith('/') ? '' : '/';
|
|
9
|
+
return `${directory}${separator}${name}`;
|
|
10
|
+
};
|
|
11
|
+
export const isFile = async (cwd, file) => {
|
|
12
|
+
try {
|
|
13
|
+
await access(path.resolve(cwd, file));
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
export const isDirectory = async (target) => {
|
|
21
|
+
try {
|
|
22
|
+
const found = await stat(target);
|
|
23
|
+
return found.isDirectory();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const kindOf = async (entry, absolute) => {
|
|
30
|
+
if (entry.isSymbolicLink()) {
|
|
31
|
+
return await isDirectory(absolute) ? undefined : 'file';
|
|
32
|
+
}
|
|
33
|
+
return entry.isDirectory() ? 'directory' : 'file';
|
|
34
|
+
};
|
|
35
|
+
const listDirectory = async (absolute) => {
|
|
36
|
+
try {
|
|
37
|
+
return await readdir(absolute, { withFileTypes: true });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const kindsOf = async (cwd, directory) => {
|
|
44
|
+
const entries = await listDirectory(path.resolve(cwd, directory));
|
|
45
|
+
const kinds = await Promise.all(entries.map(async (entry) => {
|
|
46
|
+
const kind = await kindOf(entry, path.resolve(cwd, directory, entry.name));
|
|
47
|
+
return [entry.name, kind];
|
|
48
|
+
}));
|
|
49
|
+
return new Map(kinds);
|
|
50
|
+
};
|
|
51
|
+
export const walkFiles = async (cwd, directory, extension) => {
|
|
52
|
+
const kinds = await kindsOf(cwd, directory);
|
|
53
|
+
const names = sortedText(kinds.keys());
|
|
54
|
+
const files = names.filter((name) => kinds.get(name) === 'file' && name.endsWith(extension));
|
|
55
|
+
const directories = names.filter((name) => kinds.get(name) === 'directory');
|
|
56
|
+
const nested = await Promise.all(directories
|
|
57
|
+
.map(async (name) => walkFiles(cwd, joinPath(directory, name), extension)));
|
|
58
|
+
return [...files.map((name) => joinPath(directory, name)), ...nested.flat()];
|
|
59
|
+
};
|
|
60
|
+
export const readText = async (cwd, file) => {
|
|
61
|
+
const text = await readFile(path.resolve(cwd, file), 'utf8');
|
|
62
|
+
return universalNewlines(text);
|
|
63
|
+
};
|
|
64
|
+
const readAll = async (files, read) => {
|
|
65
|
+
const texts = await Promise.all(files.map(async (file) => [file, await read(file)]));
|
|
66
|
+
return new Map(texts);
|
|
67
|
+
};
|
|
68
|
+
export const worktreeFeatures = async (cwd, config) => {
|
|
69
|
+
const files = await walkFiles(cwd, config.features, '.feature');
|
|
70
|
+
return readAll(files, async (file) => readText(cwd, file));
|
|
71
|
+
};
|
|
72
|
+
const showOrEmpty = async (cwd, revision) => {
|
|
73
|
+
const text = await git(cwd, currentRepo, ['show', revision]);
|
|
74
|
+
return text ?? '';
|
|
75
|
+
};
|
|
76
|
+
export const baseFeatures = async (cwd, config, base) => {
|
|
77
|
+
const listed = await runGit({ cwd, repo: currentRepo }, ['ls-tree', '-r', '--name-only', base, '--', config.features]);
|
|
78
|
+
if (listed.code !== 0) {
|
|
79
|
+
throw new ConfigError(`git ls-tree ${base} не прошёл: ${listed.stderr.trim()}`);
|
|
80
|
+
}
|
|
81
|
+
const files = stdoutText(listed).split('\n')
|
|
82
|
+
.filter((file) => file.endsWith('.feature'));
|
|
83
|
+
return readAll(files, async (file) => showOrEmpty(cwd, `${base}:./${file}`));
|
|
84
|
+
};
|
|
85
|
+
export const allScenarios = (files) => [...files]
|
|
86
|
+
.flatMap(([file, text]) => parseFeature(text, file));
|
|
87
|
+
export const scenarioIds = (config, files) => {
|
|
88
|
+
const tagged = allScenarios(files).flatMap((scenario) => scenario.tags
|
|
89
|
+
.filter((tag) => config.id.test(tag))
|
|
90
|
+
.map((tag) => ({ scenario, tag })));
|
|
91
|
+
const grouped = Map.groupBy(tagged, ({ tag }) => tag);
|
|
92
|
+
return new Map([...grouped].map(([tag, items]) => [tag, items.map(({ scenario }) => scenario)]));
|
|
93
|
+
};
|
|
94
|
+
export const countMark = (scenarios, mark) => scenarios
|
|
95
|
+
.filter((scenario) => scenario.tags.includes(mark))
|
|
96
|
+
.length;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ScenarioInterface {
|
|
2
|
+
readonly file: string;
|
|
3
|
+
readonly line: number;
|
|
4
|
+
readonly tags: readonly string[];
|
|
5
|
+
readonly title: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const parseFeature: (text: string, file: string) => ScenarioInterface[];
|
|
8
|
+
export declare const featureTags: (text: string) => readonly string[];
|
|
9
|
+
export declare const featureLanguage: (text: string) => string;
|
|
10
|
+
export declare const where: (scenario: ScenarioInterface) => string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { splitLines } from './python-text.js';
|
|
2
|
+
const startFeature = (state) => {
|
|
3
|
+
state.featureTags = state.pendingTags;
|
|
4
|
+
state.ruleTags = [];
|
|
5
|
+
};
|
|
6
|
+
const startRule = (state) => {
|
|
7
|
+
state.ruleTags = state.pendingTags;
|
|
8
|
+
};
|
|
9
|
+
const addScenario = (state, line, number) => {
|
|
10
|
+
state.scenarios.push({
|
|
11
|
+
line: number,
|
|
12
|
+
tags: [...state.featureTags, ...state.ruleTags, ...state.pendingTags],
|
|
13
|
+
title: line.slice(line.indexOf(':') + 1).trim(),
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
const skipTags = () => {
|
|
17
|
+
};
|
|
18
|
+
const headings = [
|
|
19
|
+
[['Feature', 'Business Need', 'Ability', 'Функция', 'Функциональность', 'Функционал', 'Свойство'], startFeature],
|
|
20
|
+
[['Rule', 'Правило'], startRule],
|
|
21
|
+
[
|
|
22
|
+
[
|
|
23
|
+
'Scenario Outline',
|
|
24
|
+
'Scenario Template',
|
|
25
|
+
'Scenario',
|
|
26
|
+
'Example',
|
|
27
|
+
'Структура сценария',
|
|
28
|
+
'Шаблон сценария',
|
|
29
|
+
'Сценарий',
|
|
30
|
+
'Пример',
|
|
31
|
+
],
|
|
32
|
+
addScenario,
|
|
33
|
+
],
|
|
34
|
+
[['Examples', 'Scenarios', 'Примеры', 'Background', 'Предыстория', 'Контекст'], skipTags],
|
|
35
|
+
];
|
|
36
|
+
const headingOf = (line) => headings
|
|
37
|
+
.find(([words]) => words.some((word) => line.startsWith(`${word}:`)))?.[1];
|
|
38
|
+
const commentTail = /#.*/u;
|
|
39
|
+
const lineTags = (line) => line
|
|
40
|
+
.replace(commentTail, '')
|
|
41
|
+
.split(/\s/u)
|
|
42
|
+
.filter((tag) => tag.startsWith('@'))
|
|
43
|
+
.map((tag) => tag.slice(1));
|
|
44
|
+
const consumeLine = (state, line, number) => {
|
|
45
|
+
if (line.startsWith('@')) {
|
|
46
|
+
state.pendingTags = [...state.pendingTags, ...lineTags(line)];
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const heading = headingOf(line);
|
|
50
|
+
if (heading === undefined) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
heading(state, line, number);
|
|
54
|
+
state.pendingTags = [];
|
|
55
|
+
};
|
|
56
|
+
const parseState = (text) => {
|
|
57
|
+
const state = { featureTags: [], pendingTags: [], ruleTags: [], scenarios: [] };
|
|
58
|
+
for (const [index, raw] of splitLines(text).entries()) {
|
|
59
|
+
consumeLine(state, raw.trim(), index + 1);
|
|
60
|
+
}
|
|
61
|
+
return state;
|
|
62
|
+
};
|
|
63
|
+
export const parseFeature = (text, file) => parseState(text).scenarios
|
|
64
|
+
.map((scenario) => ({ file, ...scenario }));
|
|
65
|
+
export const featureTags = (text) => parseState(text).featureTags;
|
|
66
|
+
const languageLine = /^[\t ]*#[\t ]*language:[\t ]*(?<language>\S+)/mu;
|
|
67
|
+
export const featureLanguage = (text) => languageLine.exec(text)?.groups?.language ?? 'en';
|
|
68
|
+
export const where = (scenario) => `${scenario.file}:${scenario.line} «${scenario.title}»`;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface GitResultInterface {
|
|
2
|
+
readonly code: number;
|
|
3
|
+
readonly stderr: string;
|
|
4
|
+
readonly stdout: Buffer;
|
|
5
|
+
}
|
|
6
|
+
export interface RepoInterface {
|
|
7
|
+
readonly cwd: string;
|
|
8
|
+
readonly repo: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const currentRepo = ".";
|
|
11
|
+
export declare const runGit: (location: RepoInterface, gitArguments: readonly string[], input?: string) => Promise<GitResultInterface>;
|
|
12
|
+
export declare const stdoutText: (result: GitResultInterface) => string;
|
|
13
|
+
export declare const git: (cwd: string, repo: string, gitArguments: readonly string[]) => Promise<string | undefined>;
|
|
14
|
+
export declare const commonDirectory: (cwd: string, repo: string) => Promise<string | undefined>;
|
|
15
|
+
export declare const readAtRevision: (location: RepoInterface, revision: string, files: readonly string[]) => Promise<Map<string, string>>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ConfigError } from './config.js';
|
|
5
|
+
import { universalNewlines } from './python-text.js';
|
|
6
|
+
export const currentRepo = '.';
|
|
7
|
+
const decoder = new TextDecoder('utf-8');
|
|
8
|
+
const ignorePipeError = () => {
|
|
9
|
+
};
|
|
10
|
+
export const runGit = async (location, gitArguments, input = '') => new Promise((resolve) => {
|
|
11
|
+
const child = spawn('git', ['-C', location.repo, ...gitArguments], { cwd: location.cwd });
|
|
12
|
+
const stdout = [];
|
|
13
|
+
const stderr = [];
|
|
14
|
+
child.stdout.on('data', (chunk) => {
|
|
15
|
+
stdout.push(chunk);
|
|
16
|
+
});
|
|
17
|
+
child.stderr.on('data', (chunk) => {
|
|
18
|
+
stderr.push(chunk);
|
|
19
|
+
});
|
|
20
|
+
child.stdin.on('error', ignorePipeError);
|
|
21
|
+
child.on('error', (error) => {
|
|
22
|
+
resolve({ code: -1, stderr: error.message, stdout: Buffer.alloc(0) });
|
|
23
|
+
});
|
|
24
|
+
child.on('close', (code) => {
|
|
25
|
+
resolve({ code: code ?? -1, stderr: Buffer.concat(stderr).toString('utf8'), stdout: Buffer.concat(stdout) });
|
|
26
|
+
});
|
|
27
|
+
child.stdin.end(input);
|
|
28
|
+
});
|
|
29
|
+
export const stdoutText = (result) => universalNewlines(result.stdout.toString('utf8'));
|
|
30
|
+
export const git = async (cwd, repo, gitArguments) => {
|
|
31
|
+
const done = await runGit({ cwd, repo }, gitArguments);
|
|
32
|
+
return done.code === 0 ? stdoutText(done) : undefined;
|
|
33
|
+
};
|
|
34
|
+
const realPath = async (target) => {
|
|
35
|
+
try {
|
|
36
|
+
return await realpath(target);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return target;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
export const commonDirectory = async (cwd, repo) => {
|
|
43
|
+
const out = await git(cwd, repo, ['rev-parse', '--git-common-dir']);
|
|
44
|
+
return out === undefined ? undefined : realPath(path.resolve(cwd, repo, out.trim()));
|
|
45
|
+
};
|
|
46
|
+
const batchEntry = (batch, file, position) => {
|
|
47
|
+
const end = batch.out.indexOf(0x0A, position);
|
|
48
|
+
const header = batch.out.subarray(position, end).toString('utf8')
|
|
49
|
+
.split(' ');
|
|
50
|
+
if (header.at(-1) === 'missing') {
|
|
51
|
+
throw new ConfigError(`в ${batch.describe}:${file}`);
|
|
52
|
+
}
|
|
53
|
+
const size = Number(header[2]);
|
|
54
|
+
return [decoder.decode(batch.out.subarray(end + 1, end + 1 + size)), end + 1 + size + 1];
|
|
55
|
+
};
|
|
56
|
+
const parseBatch = (batch) => {
|
|
57
|
+
const texts = new Map();
|
|
58
|
+
let position = 0;
|
|
59
|
+
for (const file of batch.files) {
|
|
60
|
+
const [text, next] = batchEntry(batch, file, position);
|
|
61
|
+
texts.set(file, text);
|
|
62
|
+
position = next;
|
|
63
|
+
}
|
|
64
|
+
return texts;
|
|
65
|
+
};
|
|
66
|
+
export const readAtRevision = async (location, revision, files) => {
|
|
67
|
+
const done = await runGit(location, ['cat-file', '--batch'], files.map((file) => `${revision}:${file}\n`).join(''));
|
|
68
|
+
if (done.code !== 0) {
|
|
69
|
+
throw new ConfigError(`git cat-file в ${location.repo} на ${revision} не прошёл: ${done.stderr.trim()}`);
|
|
70
|
+
}
|
|
71
|
+
return parseBatch({ describe: `${location.repo} нет ${revision}`, files, out: done.stdout });
|
|
72
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export * from './acceptance-class.js';
|
|
2
|
+
export * from './board.js';
|
|
3
|
+
export * from './classes.js';
|
|
4
|
+
export * from './classify.js';
|
|
5
|
+
export * from './config.js';
|
|
6
|
+
export * from './coverage.js';
|
|
7
|
+
export * from './coverage-data.js';
|
|
8
|
+
export * from './coverage-json.js';
|
|
9
|
+
export * from './coverage-open.js';
|
|
10
|
+
export * from './coverage-page.js';
|
|
11
|
+
export * from './coverage-removed.js';
|
|
12
|
+
export * from './dates.js';
|
|
13
|
+
export * from './declared.js';
|
|
14
|
+
export * from './features.js';
|
|
15
|
+
export * from './gherkin.js';
|
|
16
|
+
export * from './git.js';
|
|
17
|
+
export * from './inventory.js';
|
|
18
|
+
export * from './links.js';
|
|
19
|
+
export * from './lint.js';
|
|
20
|
+
export * from './lint-inputs.js';
|
|
21
|
+
export * from './projects.js';
|
|
22
|
+
export * from './python-text.js';
|
|
23
|
+
export * from './remote.js';
|
|
24
|
+
export * from './removed-list.js';
|
|
25
|
+
export * from './report.js';
|
|
26
|
+
export * from './run.js';
|
|
27
|
+
export * from './task-links.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export * from './acceptance-class.js';
|
|
2
|
+
export * from './board.js';
|
|
3
|
+
export * from './classes.js';
|
|
4
|
+
export * from './classify.js';
|
|
5
|
+
export * from './config.js';
|
|
6
|
+
export * from './coverage.js';
|
|
7
|
+
export * from './coverage-data.js';
|
|
8
|
+
export * from './coverage-json.js';
|
|
9
|
+
export * from './coverage-open.js';
|
|
10
|
+
export * from './coverage-page.js';
|
|
11
|
+
export * from './coverage-removed.js';
|
|
12
|
+
export * from './dates.js';
|
|
13
|
+
export * from './declared.js';
|
|
14
|
+
export * from './features.js';
|
|
15
|
+
export * from './gherkin.js';
|
|
16
|
+
export * from './git.js';
|
|
17
|
+
export * from './inventory.js';
|
|
18
|
+
export * from './links.js';
|
|
19
|
+
export * from './lint.js';
|
|
20
|
+
export * from './lint-inputs.js';
|
|
21
|
+
export * from './projects.js';
|
|
22
|
+
export * from './python-text.js';
|
|
23
|
+
export * from './remote.js';
|
|
24
|
+
export * from './removed-list.js';
|
|
25
|
+
export * from './report.js';
|
|
26
|
+
export * from './run.js';
|
|
27
|
+
export * from './task-links.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ConfigInterface } from './config.js';
|
|
2
|
+
import type { ReportInterface } from './report.js';
|
|
3
|
+
export declare const inventoryContract = 1;
|
|
4
|
+
export interface InventoryOptionsInterface {
|
|
5
|
+
readonly out: string;
|
|
6
|
+
readonly stories: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export declare const inventoryCommand: (cwd: string, config: ConfigInterface, options: InventoryOptionsInterface) => Promise<ReportInterface>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { acceptanceFields } from './acceptance-class.js';
|
|
4
|
+
import { ConfigError } from './config.js';
|
|
5
|
+
import { allScenarios, readText, worktreeFeatures } from './features.js';
|
|
6
|
+
import { featureLanguage, featureTags } from './gherkin.js';
|
|
7
|
+
import { messageOf } from './python-text.js';
|
|
8
|
+
import { parseRemoved } from './removed-list.js';
|
|
9
|
+
export const inventoryContract = 1;
|
|
10
|
+
const fieldJson = (field) => ({
|
|
11
|
+
kinds: field.kinds,
|
|
12
|
+
line: field.line,
|
|
13
|
+
problem: field.problem ?? null,
|
|
14
|
+
reason: field.reason,
|
|
15
|
+
});
|
|
16
|
+
const storyFields = async (cwd, file) => {
|
|
17
|
+
try {
|
|
18
|
+
return { fields: acceptanceFields(await readText(cwd, file)), file };
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw new ConfigError(`story ${file} не читается: ${messageOf(error)}`);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const filesJson = (files) => [...files]
|
|
25
|
+
.map(([file, text]) => ({ language: featureLanguage(text), path: file, tags: featureTags(text) }));
|
|
26
|
+
export const inventoryCommand = async (cwd, config, options) => {
|
|
27
|
+
const files = await worktreeFeatures(cwd, config);
|
|
28
|
+
const scenarios = allScenarios(files);
|
|
29
|
+
const removed = await parseRemoved(cwd, config.removed);
|
|
30
|
+
const stories = await Promise.all(options.stories.map(async (file) => storyFields(cwd, file)));
|
|
31
|
+
const inventory = {
|
|
32
|
+
config: { features: config.features, groups: config.groups, id: config.id.source, removed: config.removed },
|
|
33
|
+
contract: inventoryContract,
|
|
34
|
+
files: filesJson(files),
|
|
35
|
+
removed: [...removed.entries].map(([id, line]) => ({ id, line })),
|
|
36
|
+
scenarios: scenarios.map((scenario) => ({ ...scenario, ids: scenario.tags.filter((tag) => config.id.test(tag)) })),
|
|
37
|
+
stories: stories.map((story) => ({ fields: story.fields.map((field) => fieldJson(field)), file: story.file })),
|
|
38
|
+
};
|
|
39
|
+
const target = path.resolve(cwd, options.out);
|
|
40
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
41
|
+
await writeFile(target, `${JSON.stringify(inventory, undefined, 2)}\n`);
|
|
42
|
+
return {
|
|
43
|
+
defects: [],
|
|
44
|
+
notes: [],
|
|
45
|
+
summary: `инвентарь: feature-файлов ${files.size}, сценариев ${scenarios.length}, снятых ${removed.entries.size}, `
|
|
46
|
+
+ `story ${stories.length} → ${options.out}`,
|
|
47
|
+
};
|
|
48
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ConfigInterface } from './config.js';
|
|
2
|
+
import type { ProjectsInputInterface } from './projects.js';
|
|
3
|
+
import type { ReportInterface } from './report.js';
|
|
4
|
+
export declare const linksCommand: (config: ConfigInterface, input: ProjectsInputInterface) => Promise<ReportInterface>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { isProjectCovered, markPending, markQuarantine, markTodo } from './config.js';
|
|
2
|
+
import { allScenarios, countMark, scenarioIds, worktreeFeatures } from './features.js';
|
|
3
|
+
import { where } from './gherkin.js';
|
|
4
|
+
import { compareText } from './python-text.js';
|
|
5
|
+
import { parseRemoved } from './removed-list.js';
|
|
6
|
+
import { linkToken, readProjects } from './task-links.js';
|
|
7
|
+
const wideFirst = new Set(['*', '\\', '<']);
|
|
8
|
+
const wideAfter = new Set(['.*', '.<']);
|
|
9
|
+
const templateStory = (tag) => {
|
|
10
|
+
const dot = tag.lastIndexOf('.');
|
|
11
|
+
return dot === -1 ? tag : tag.slice(0, dot);
|
|
12
|
+
};
|
|
13
|
+
const storyWide = (config, link) => {
|
|
14
|
+
if (!config.id.test(link.tag)) {
|
|
15
|
+
return ['шаблон вместо номера', templateStory(link.tag)];
|
|
16
|
+
}
|
|
17
|
+
const isWhole = wideFirst.has(link.after.slice(0, 1)) || wideAfter.has(link.after);
|
|
18
|
+
return isWhole ? ['story целиком', link.tag] : undefined;
|
|
19
|
+
};
|
|
20
|
+
const problemOutcome = (link, problem) => (link.isClosed
|
|
21
|
+
? { defect: `ссылка из закрытой story никуда не ведёт (${problem}): @${link.tag} — ${link.location}` }
|
|
22
|
+
: { todo: `завести: @${link.tag} — ${link.location} (${problem}, story ${link.status || 'вне board'})` });
|
|
23
|
+
const liveOutcome = (link, owner) => {
|
|
24
|
+
if (owner.tags.includes(markTodo)) {
|
|
25
|
+
return { debt: link };
|
|
26
|
+
}
|
|
27
|
+
const isPendingClosed = link.isClosed && owner.tags.includes(markPending);
|
|
28
|
+
const defect = `закрытая story ссылается на сценарий «ждёт реализации» @${link.tag}: ${link.location} → ${where(owner)}`;
|
|
29
|
+
return isPendingClosed ? { defect } : {};
|
|
30
|
+
};
|
|
31
|
+
const wideOutcome = (known, link, wide) => {
|
|
32
|
+
const [kind, story] = wide;
|
|
33
|
+
const hasScenarios = [...known.ids.keys(), ...known.removed.keys()].some((id) => id.startsWith(`${story}.`));
|
|
34
|
+
return hasScenarios ? {} : problemOutcome(link, `${kind}, а сценариев этой story нет`);
|
|
35
|
+
};
|
|
36
|
+
const exactOutcome = (known, link) => {
|
|
37
|
+
const owner = known.ids.get(link.tag)?.[0];
|
|
38
|
+
if (owner !== undefined) {
|
|
39
|
+
return liveOutcome(link, owner);
|
|
40
|
+
}
|
|
41
|
+
return known.removed.has(link.tag) ? {} : problemOutcome(link, 'сценария нет');
|
|
42
|
+
};
|
|
43
|
+
const outcomeOf = (config, known, link) => {
|
|
44
|
+
const wide = storyWide(config, link);
|
|
45
|
+
return wide === undefined ? exactOutcome(known, link) : wideOutcome(known, link, wide);
|
|
46
|
+
};
|
|
47
|
+
const linkOf = (row, match) => {
|
|
48
|
+
const end = match.index + match[0].length;
|
|
49
|
+
return {
|
|
50
|
+
after: row.text.slice(end, end + 2),
|
|
51
|
+
isClosed: row.status === 'done',
|
|
52
|
+
location: `${row.project}/${row.file}:${row.line}`,
|
|
53
|
+
status: row.status,
|
|
54
|
+
tag: match[0].slice(1),
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const rowLinks = (config, row) => [...row.text.matchAll(linkToken)]
|
|
58
|
+
.filter((match) => isProjectCovered(config, String(match[1])))
|
|
59
|
+
.map((match) => linkOf(row, match));
|
|
60
|
+
const summaryOf = (scenarios, counts) => {
|
|
61
|
+
const pending = countMark(scenarios, markPending);
|
|
62
|
+
const quarantine = countMark(scenarios, markQuarantine);
|
|
63
|
+
const todo = countMark(scenarios, markTodo);
|
|
64
|
+
const tested = `с тестом ${scenarios.length - todo}, из них @${markPending} ${pending}, @${markQuarantine} ${quarantine}`;
|
|
65
|
+
const links = `ссылок из закрытых story ${counts.closed}, из незакрытых ${counts.open}`;
|
|
66
|
+
return `прочитано: сценариев ${scenarios.length} (${tested}; долгов @${markTodo} ${todo}), снятых ${counts.removed}; ${links}`;
|
|
67
|
+
};
|
|
68
|
+
const debtLine = (id, owner, references) => `долг: @${id} — ${where(owner)}; ссылки: ${references.join(', ') || 'нет'}`;
|
|
69
|
+
const debtLines = (known, outcomes) => [...known.ids]
|
|
70
|
+
.toSorted(([left], [right]) => compareText(left, right))
|
|
71
|
+
.flatMap(([id, owners]) => owners.slice(0, 1)
|
|
72
|
+
.filter((owner) => owner.tags.includes(markTodo))
|
|
73
|
+
.map((owner) => debtLine(id, owner, outcomes.flatMap(({ debt }) => (debt?.tag === id ? [debt.location] : [])))));
|
|
74
|
+
const knownOf = async (config, input, ids) => {
|
|
75
|
+
const removed = await parseRemoved(input.cwd, config.removed);
|
|
76
|
+
return { ids, removed: removed.entries };
|
|
77
|
+
};
|
|
78
|
+
export const linksCommand = async (config, input) => {
|
|
79
|
+
const files = await worktreeFeatures(input.cwd, config);
|
|
80
|
+
const known = await knownOf(config, input, scenarioIds(config, files));
|
|
81
|
+
const read = await readProjects(input);
|
|
82
|
+
const links = read.rows.flatMap((row) => rowLinks(config, row));
|
|
83
|
+
const outcomes = links.map((link) => outcomeOf(config, known, link));
|
|
84
|
+
const closed = links.filter((link) => link.isClosed).length;
|
|
85
|
+
const summary = summaryOf(allScenarios(files), { closed, open: links.length - closed, removed: known.removed.size });
|
|
86
|
+
return {
|
|
87
|
+
defects: outcomes.flatMap((outcome) => outcome.defect ?? []),
|
|
88
|
+
notes: read.notes,
|
|
89
|
+
summary: [
|
|
90
|
+
`${summary}\nзадачи: ${read.sources.join('; ') || 'нет'}`,
|
|
91
|
+
...debtLines(known, outcomes),
|
|
92
|
+
...outcomes.flatMap((outcome) => outcome.todo ?? []),
|
|
93
|
+
].join('\n'),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ConfigInterface } from './config.js';
|
|
2
|
+
export interface ListResultInterface {
|
|
3
|
+
readonly defects: readonly string[];
|
|
4
|
+
readonly notes: readonly string[];
|
|
5
|
+
readonly tests: number;
|
|
6
|
+
}
|
|
7
|
+
export interface UnusedResultInterface {
|
|
8
|
+
readonly count: number;
|
|
9
|
+
readonly defects: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
export declare const lintList: (config: ConfigInterface, cwd: string, file: string) => Promise<ListResultInterface>;
|
|
12
|
+
export declare const lintUnused: (cwd: string, file: string) => Promise<UnusedResultInterface>;
|