@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.
Files changed (152) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cjs/bin/application-scenarios.d.ts +2 -0
  3. package/dist/cjs/bin/application-scenarios.js +15 -0
  4. package/dist/cjs/bin/index.d.ts +1 -0
  5. package/dist/cjs/bin/index.js +17 -0
  6. package/dist/cjs/cadence/index.d.ts +1 -0
  7. package/dist/cjs/cadence/index.js +17 -0
  8. package/dist/cjs/cadence/resolve-cadence.d.ts +8 -0
  9. package/dist/cjs/cadence/resolve-cadence.js +18 -0
  10. package/dist/cjs/environments/environment.interface.d.ts +5 -0
  11. package/dist/cjs/environments/environment.interface.js +2 -0
  12. package/dist/cjs/environments/index.d.ts +3 -0
  13. package/dist/cjs/environments/index.js +19 -0
  14. package/dist/cjs/environments/resolve-environment.d.ts +10 -0
  15. package/dist/cjs/environments/resolve-environment.js +20 -0
  16. package/dist/cjs/environments/secrets.d.ts +4 -0
  17. package/dist/cjs/environments/secrets.js +14 -0
  18. package/dist/cjs/index.d.ts +2 -0
  19. package/dist/cjs/index.js +18 -0
  20. package/dist/cjs/package.json +3 -0
  21. package/dist/cjs/scenarios/acceptance-class.d.ts +26 -0
  22. package/dist/cjs/scenarios/acceptance-class.js +100 -0
  23. package/dist/cjs/scenarios/board.d.ts +6 -0
  24. package/dist/cjs/scenarios/board.js +98 -0
  25. package/dist/cjs/scenarios/classes.d.ts +16 -0
  26. package/dist/cjs/scenarios/classes.js +48 -0
  27. package/dist/cjs/scenarios/classify.d.ts +35 -0
  28. package/dist/cjs/scenarios/classify.js +110 -0
  29. package/dist/cjs/scenarios/config.d.ts +17 -0
  30. package/dist/cjs/scenarios/config.js +67 -0
  31. package/dist/cjs/scenarios/coverage-data.d.ts +53 -0
  32. package/dist/cjs/scenarios/coverage-data.js +118 -0
  33. package/dist/cjs/scenarios/coverage-json.d.ts +2 -0
  34. package/dist/cjs/scenarios/coverage-json.js +94 -0
  35. package/dist/cjs/scenarios/coverage-open.d.ts +16 -0
  36. package/dist/cjs/scenarios/coverage-open.js +76 -0
  37. package/dist/cjs/scenarios/coverage-page.d.ts +2 -0
  38. package/dist/cjs/scenarios/coverage-page.js +108 -0
  39. package/dist/cjs/scenarios/coverage-removed.d.ts +13 -0
  40. package/dist/cjs/scenarios/coverage-removed.js +30 -0
  41. package/dist/cjs/scenarios/coverage.d.ts +26 -0
  42. package/dist/cjs/scenarios/coverage.js +91 -0
  43. package/dist/cjs/scenarios/dates.d.ts +8 -0
  44. package/dist/cjs/scenarios/dates.js +93 -0
  45. package/dist/cjs/scenarios/declared.d.ts +18 -0
  46. package/dist/cjs/scenarios/declared.js +69 -0
  47. package/dist/cjs/scenarios/features.d.ts +13 -0
  48. package/dist/cjs/scenarios/features.js +112 -0
  49. package/dist/cjs/scenarios/gherkin.d.ts +10 -0
  50. package/dist/cjs/scenarios/gherkin.js +75 -0
  51. package/dist/cjs/scenarios/git.d.ts +15 -0
  52. package/dist/cjs/scenarios/git.js +83 -0
  53. package/dist/cjs/scenarios/index.d.ts +27 -0
  54. package/dist/cjs/scenarios/index.js +43 -0
  55. package/dist/cjs/scenarios/inventory.d.ts +8 -0
  56. package/dist/cjs/scenarios/inventory.js +55 -0
  57. package/dist/cjs/scenarios/links.d.ts +4 -0
  58. package/dist/cjs/scenarios/links.js +99 -0
  59. package/dist/cjs/scenarios/lint-inputs.d.ts +12 -0
  60. package/dist/cjs/scenarios/lint-inputs.js +118 -0
  61. package/dist/cjs/scenarios/lint.d.ts +8 -0
  62. package/dist/cjs/scenarios/lint.js +94 -0
  63. package/dist/cjs/scenarios/projects.d.ts +21 -0
  64. package/dist/cjs/scenarios/projects.js +115 -0
  65. package/dist/cjs/scenarios/python-text.d.ts +11 -0
  66. package/dist/cjs/scenarios/python-text.js +95 -0
  67. package/dist/cjs/scenarios/remote.d.ts +6 -0
  68. package/dist/cjs/scenarios/remote.js +29 -0
  69. package/dist/cjs/scenarios/removed-list.d.ts +15 -0
  70. package/dist/cjs/scenarios/removed-list.js +62 -0
  71. package/dist/cjs/scenarios/report.d.ts +6 -0
  72. package/dist/cjs/scenarios/report.js +10 -0
  73. package/dist/cjs/scenarios/run.d.ts +10 -0
  74. package/dist/cjs/scenarios/run.js +145 -0
  75. package/dist/cjs/scenarios/task-links.d.ts +15 -0
  76. package/dist/cjs/scenarios/task-links.js +54 -0
  77. package/dist/esm/bin/application-scenarios.d.ts +2 -0
  78. package/dist/esm/bin/application-scenarios.js +10 -0
  79. package/dist/esm/bin/index.d.ts +1 -0
  80. package/dist/esm/bin/index.js +1 -0
  81. package/dist/esm/cadence/index.d.ts +1 -0
  82. package/dist/esm/cadence/index.js +1 -0
  83. package/dist/esm/cadence/resolve-cadence.d.ts +8 -0
  84. package/dist/esm/cadence/resolve-cadence.js +14 -0
  85. package/dist/esm/environments/environment.interface.d.ts +5 -0
  86. package/dist/esm/environments/environment.interface.js +1 -0
  87. package/dist/esm/environments/index.d.ts +3 -0
  88. package/dist/esm/environments/index.js +3 -0
  89. package/dist/esm/environments/resolve-environment.d.ts +10 -0
  90. package/dist/esm/environments/resolve-environment.js +16 -0
  91. package/dist/esm/environments/secrets.d.ts +4 -0
  92. package/dist/esm/environments/secrets.js +9 -0
  93. package/dist/esm/index.d.ts +2 -0
  94. package/dist/esm/index.js +2 -0
  95. package/dist/esm/scenarios/acceptance-class.d.ts +26 -0
  96. package/dist/esm/scenarios/acceptance-class.js +93 -0
  97. package/dist/esm/scenarios/board.d.ts +6 -0
  98. package/dist/esm/scenarios/board.js +92 -0
  99. package/dist/esm/scenarios/classes.d.ts +16 -0
  100. package/dist/esm/scenarios/classes.js +42 -0
  101. package/dist/esm/scenarios/classify.d.ts +35 -0
  102. package/dist/esm/scenarios/classify.js +106 -0
  103. package/dist/esm/scenarios/config.d.ts +17 -0
  104. package/dist/esm/scenarios/config.js +58 -0
  105. package/dist/esm/scenarios/coverage-data.d.ts +53 -0
  106. package/dist/esm/scenarios/coverage-data.js +109 -0
  107. package/dist/esm/scenarios/coverage-json.d.ts +2 -0
  108. package/dist/esm/scenarios/coverage-json.js +90 -0
  109. package/dist/esm/scenarios/coverage-open.d.ts +16 -0
  110. package/dist/esm/scenarios/coverage-open.js +71 -0
  111. package/dist/esm/scenarios/coverage-page.d.ts +2 -0
  112. package/dist/esm/scenarios/coverage-page.js +104 -0
  113. package/dist/esm/scenarios/coverage-removed.d.ts +13 -0
  114. package/dist/esm/scenarios/coverage-removed.js +26 -0
  115. package/dist/esm/scenarios/coverage.d.ts +26 -0
  116. package/dist/esm/scenarios/coverage.js +84 -0
  117. package/dist/esm/scenarios/dates.d.ts +8 -0
  118. package/dist/esm/scenarios/dates.js +87 -0
  119. package/dist/esm/scenarios/declared.d.ts +18 -0
  120. package/dist/esm/scenarios/declared.js +62 -0
  121. package/dist/esm/scenarios/features.d.ts +13 -0
  122. package/dist/esm/scenarios/features.js +96 -0
  123. package/dist/esm/scenarios/gherkin.d.ts +10 -0
  124. package/dist/esm/scenarios/gherkin.js +68 -0
  125. package/dist/esm/scenarios/git.d.ts +15 -0
  126. package/dist/esm/scenarios/git.js +72 -0
  127. package/dist/esm/scenarios/index.d.ts +27 -0
  128. package/dist/esm/scenarios/index.js +27 -0
  129. package/dist/esm/scenarios/inventory.d.ts +8 -0
  130. package/dist/esm/scenarios/inventory.js +48 -0
  131. package/dist/esm/scenarios/links.d.ts +4 -0
  132. package/dist/esm/scenarios/links.js +95 -0
  133. package/dist/esm/scenarios/lint-inputs.d.ts +12 -0
  134. package/dist/esm/scenarios/lint-inputs.js +110 -0
  135. package/dist/esm/scenarios/lint.d.ts +8 -0
  136. package/dist/esm/scenarios/lint.js +89 -0
  137. package/dist/esm/scenarios/projects.d.ts +21 -0
  138. package/dist/esm/scenarios/projects.js +104 -0
  139. package/dist/esm/scenarios/python-text.d.ts +11 -0
  140. package/dist/esm/scenarios/python-text.js +81 -0
  141. package/dist/esm/scenarios/remote.d.ts +6 -0
  142. package/dist/esm/scenarios/remote.js +24 -0
  143. package/dist/esm/scenarios/removed-list.d.ts +15 -0
  144. package/dist/esm/scenarios/removed-list.js +57 -0
  145. package/dist/esm/scenarios/report.d.ts +6 -0
  146. package/dist/esm/scenarios/report.js +6 -0
  147. package/dist/esm/scenarios/run.d.ts +10 -0
  148. package/dist/esm/scenarios/run.js +141 -0
  149. package/dist/esm/scenarios/task-links.d.ts +15 -0
  150. package/dist/esm/scenarios/task-links.js +50 -0
  151. package/package.json +65 -3
  152. package/README.md +0 -3
@@ -0,0 +1,110 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ConfigError, markPending, marks } from './config.js';
4
+ import { messageOf, pyText, sortedText, splitLines } from './python-text.js';
5
+ const unusedHeader = /Unused steps \((?<count>\d+)\)/u;
6
+ const unusedRow = /^[|│]\s*(?<pattern>.+?)\s*[|│]\s*(?<location>\S+:\d+)\s*[|│]\s*$/u;
7
+ const isJson = (value) => typeof value === 'object' && value !== null;
8
+ const asRecord = (value) => (isJson(value) ? value : {});
9
+ const asList = (value) => (Array.isArray(value) ? value : []);
10
+ const readInput = async (cwd, file, describe) => {
11
+ try {
12
+ return await readFile(path.resolve(cwd, file), 'utf8');
13
+ }
14
+ catch (error) {
15
+ throw new ConfigError(`${describe} ${file} не читается: ${messageOf(error)}`);
16
+ }
17
+ };
18
+ const readJson = async (cwd, file) => {
19
+ const text = await readInput(cwd, file, 'список тестов');
20
+ try {
21
+ return asRecord(JSON.parse(text));
22
+ }
23
+ catch (error) {
24
+ throw new ConfigError(`список тестов ${file} не читается: ${messageOf(error)}`);
25
+ }
26
+ };
27
+ const walkSpecs = (suite) => [
28
+ ...asList(suite.specs).map((spec) => asRecord(spec)),
29
+ ...asList(suite.suites).flatMap((child) => walkSpecs(asRecord(child))),
30
+ ];
31
+ const errorText = (error) => {
32
+ const record = asRecord(error);
33
+ return pyText('message' in record ? record.message : error);
34
+ };
35
+ const specTags = (spec) => asList(spec.tags).map(String);
36
+ const markedTags = (spec) => {
37
+ const tags = new Set(specTags(spec));
38
+ return sortedText(marks.filter((mark) => tags.has(mark)));
39
+ };
40
+ const blockingDefects = (context, spec, project) => {
41
+ const marked = markedTags(spec).map((mark) => `@${mark}`)
42
+ .join(', ');
43
+ const isLeak = marked !== '' && context.blocking.includes(project);
44
+ return isLeak ? [`блокирующий проект «${project}» выбирает отмеченный сценарий (${marked}): ${pyText(spec.title)}`] : [];
45
+ };
46
+ const pendingDefects = (context, spec, project) => {
47
+ const isStray = project === context.pending && !specTags(spec).includes(markPending);
48
+ return isStray ? [`проект «${context.pending}» выбирает сценарий без @${markPending}: ${pyText(spec.title)}`] : [];
49
+ };
50
+ const testDefects = (context, spec, test) => {
51
+ const project = pyText(test.projectName);
52
+ return [...blockingDefects(context, spec, project), ...pendingDefects(context, spec, project)];
53
+ };
54
+ const projectDefects = (data, context) => {
55
+ const projects = asList(asRecord(data.config).projects).map((project) => pyText(asRecord(project).name));
56
+ const listed = projects.join(', ') || 'нет';
57
+ return [...context.blocking, context.pending]
58
+ .filter((name) => !projects.includes(name))
59
+ .map((name) => `нет проекта Playwright «${name}» (проекты: ${listed})`);
60
+ };
61
+ const contextOf = (config) => {
62
+ if (config.blocking.length === 0 || config.pending === undefined) {
63
+ throw new ConfigError('для --list-json в конфиге нужны «blocking» и «pending»');
64
+ }
65
+ return { blocking: config.blocking, pending: config.pending };
66
+ };
67
+ const specTests = (data) => asList(data.suites)
68
+ .flatMap((suite) => walkSpecs(asRecord(suite)))
69
+ .flatMap((spec) => asList(spec.tests).map((test) => [spec, asRecord(test)]));
70
+ export const lintList = async (config, cwd, file) => {
71
+ const data = await readJson(cwd, file);
72
+ const errors = asList(data.errors).map((error) => `playwright --list: ${errorText(error)}`);
73
+ const context = contextOf(config);
74
+ const pairs = specTests(data);
75
+ const isMarked = pairs.some(([spec]) => markedTags(spec).length > 0);
76
+ return {
77
+ defects: [
78
+ ...errors,
79
+ ...projectDefects(data, context),
80
+ ...pairs.flatMap(([spec, test]) => testDefects(context, spec, test)),
81
+ ],
82
+ notes: isMarked ? [] : ['отмеченных сценариев в списке нет — исключение их блокирующим проектом не проверено'],
83
+ tests: pairs.length,
84
+ };
85
+ };
86
+ const unusedRowOf = (line) => {
87
+ const groups = unusedRow.exec(line)?.groups;
88
+ return groups === undefined ? [] : [[String(groups.pattern), String(groups.location)]];
89
+ };
90
+ const duplicateSteps = (rows) => sortedText(new Set(rows.map(([pattern]) => pattern)))
91
+ .map((pattern) => [pattern, rows.filter(([name]) => name === pattern).map(([, location]) => location)])
92
+ .filter(([, locations]) => locations.length > 1)
93
+ .map(([pattern, locations]) => `дубль шага «${pattern}»: ${locations.join(', ')}`);
94
+ const declaredCount = (text, file) => {
95
+ const groups = unusedHeader.exec(text)?.groups;
96
+ if (groups === undefined) {
97
+ throw new ConfigError(`в ${file} нет строки «Unused steps (N)» — формат вывода bddgen сменился`);
98
+ }
99
+ return Number(groups.count);
100
+ };
101
+ export const lintUnused = async (cwd, file) => {
102
+ const text = await readInput(cwd, file, 'вывод неиспользуемых шагов');
103
+ const declared = declaredCount(text, file);
104
+ const rows = splitLines(text).flatMap((line) => unusedRowOf(line));
105
+ if (rows.length !== declared) {
106
+ throw new ConfigError(`bddgen объявил ${declared} неиспользуемых шагов, разобрано ${rows.length} — формат вывода сменился`);
107
+ }
108
+ const unused = rows.map(([pattern, location]) => `неиспользуемый шаг «${pattern}»: ${location}`);
109
+ return { count: rows.length, defects: [...unused, ...duplicateSteps(rows)] };
110
+ };
@@ -0,0 +1,8 @@
1
+ import type { ConfigInterface } from './config.js';
2
+ import type { ReportInterface } from './report.js';
3
+ export interface LintOptionsInterface {
4
+ readonly listJson: string | undefined;
5
+ readonly unusedSteps: string | undefined;
6
+ }
7
+ export declare const lintCommand: (cwd: string, config: ConfigInterface, options: LintOptionsInterface) => Promise<ReportInterface>;
8
+ export declare const removedCommand: (cwd: string, config: ConfigInterface, base: string) => Promise<ReportInterface>;
@@ -0,0 +1,89 @@
1
+ import { markPending, markQuarantine, marks, markTodo } from './config.js';
2
+ import { allScenarios, baseFeatures, countMark, scenarioIds, worktreeFeatures } from './features.js';
3
+ import { where } from './gherkin.js';
4
+ import { lintList, lintUnused } from './lint-inputs.js';
5
+ import { compareText, sortedText } from './python-text.js';
6
+ import { parseRemoved } from './removed-list.js';
7
+ const noPart = { defects: [], notes: [], summary: '' };
8
+ const isGiven = (file) => file !== undefined && file !== '';
9
+ const idDefects = (config, scenario) => {
10
+ const ids = scenario.tags.filter((tag) => config.id.test(tag));
11
+ if (ids.length === 0) {
12
+ return [`без id критерия: ${where(scenario)}`];
13
+ }
14
+ return ids.length > 1 ? [`несколько id (${ids.join(', ')}): ${where(scenario)}`] : [];
15
+ };
16
+ const groupDefects = (config, scenario) => {
17
+ const hasGroup = scenario.tags.some((tag) => config.groups.includes(tag));
18
+ return hasGroup ? [] : [`без тега группы (${config.groups.join(', ')}): ${where(scenario)}`];
19
+ };
20
+ const todoDefects = (scenario) => {
21
+ const others = sortedText(new Set(scenario.tags.filter((tag) => tag === markPending || tag === markQuarantine)));
22
+ const listed = others.map((mark) => `@${mark}`).join(', ');
23
+ const isMixed = others.length > 0 && scenario.tags.includes(markTodo);
24
+ return isMixed ? [`@${markTodo} вместе с ${listed} — теста ещё нет, отметка состояния теста ему не нужна: ${where(scenario)}`] : [];
25
+ };
26
+ const scenarioDefects = (config, scenario) => [
27
+ ...idDefects(config, scenario),
28
+ ...groupDefects(config, scenario),
29
+ ...todoDefects(scenario),
30
+ ];
31
+ const duplicateIds = (ids) => sortedText(ids.keys())
32
+ .map((tag) => [tag, ids.get(tag) ?? []])
33
+ .filter(([, owners]) => owners.length > 1)
34
+ .map(([tag, owners]) => `id ${tag} у ${owners.length} сценариев: ${owners.map((owner) => where(owner)).join('; ')}`);
35
+ const listPart = async (config, cwd, file) => {
36
+ if (!isGiven(file)) {
37
+ return noPart;
38
+ }
39
+ const result = await lintList(config, cwd, file);
40
+ return { ...result, summary: `, тестов в списке Playwright ${result.tests}` };
41
+ };
42
+ const unusedPart = async (cwd, file) => {
43
+ if (!isGiven(file)) {
44
+ return noPart;
45
+ }
46
+ const result = await lintUnused(cwd, file);
47
+ return { defects: result.defects, notes: [], summary: `, неиспользуемых шагов ${result.count}` };
48
+ };
49
+ export const lintCommand = async (cwd, config, options) => {
50
+ const files = await worktreeFeatures(cwd, config);
51
+ const scenarios = allScenarios(files);
52
+ const list = await listPart(config, cwd, options.listJson);
53
+ const unused = await unusedPart(cwd, options.unusedSteps);
54
+ const counts = marks.map((mark) => `@${mark} ${countMark(scenarios, mark)}`).join(', ');
55
+ const read = `прочитано: feature-файлов ${files.size}, сценариев ${scenarios.length}`;
56
+ return {
57
+ defects: [
58
+ ...scenarios.flatMap((scenario) => scenarioDefects(config, scenario)),
59
+ ...duplicateIds(scenarioIds(config, files)),
60
+ ...list.defects,
61
+ ...unused.defects,
62
+ ],
63
+ notes: list.notes,
64
+ summary: `${read}${list.summary}${unused.summary}\nотметки: ${counts}`,
65
+ };
66
+ };
67
+ const reusedDefect = (config, id, line, owner) => {
68
+ const place = where(owner);
69
+ return `id ${id} снят (${config.removed}:${line}), но снова в ${place} — id не переиспользуются и не переименовываются`;
70
+ };
71
+ export const removedCommand = async (cwd, config, base) => {
72
+ const before = scenarioIds(config, await baseFeatures(cwd, config, base));
73
+ const now = scenarioIds(config, await worktreeFeatures(cwd, config));
74
+ const removed = await parseRemoved(cwd, config.removed);
75
+ const gone = sortedText(before.keys()).filter((id) => !now.has(id) && !removed.entries.has(id));
76
+ const reused = [...removed.entries]
77
+ .toSorted(([left], [right]) => compareText(left, right))
78
+ .flatMap(([id, line]) => (now.get(id) ?? []).slice(0, 1).map((owner) => reusedDefect(config, id, line, owner)));
79
+ const read = `прочитано: id на базе ${base} ${before.size}, id на ветке ${now.size}`;
80
+ return {
81
+ defects: [
82
+ ...removed.defects,
83
+ ...gone.map((id) => `id ${id} исчез из feature-файлов без записи в ${config.removed}`),
84
+ ...reused,
85
+ ],
86
+ notes: [],
87
+ summary: `${read}, записей в ${config.removed} ${removed.entries.size}`,
88
+ };
89
+ };
@@ -0,0 +1,21 @@
1
+ export interface ProjectInterface {
2
+ readonly board: string | undefined;
3
+ readonly commit: string | undefined;
4
+ readonly name: string;
5
+ readonly repo: string;
6
+ readonly revision: string | undefined;
7
+ }
8
+ export interface ProjectsInputInterface {
9
+ readonly cwd: string;
10
+ readonly projects: string;
11
+ readonly revisions: ReadonlyMap<string, string>;
12
+ }
13
+ export interface WalkInterface {
14
+ readonly notes: readonly string[];
15
+ readonly projects: readonly ProjectInterface[];
16
+ }
17
+ export declare const parseRevisions: (cwd: string, projects: string, items: readonly string[]) => Promise<Map<string, string>>;
18
+ export declare const walkProjects: (input: ProjectsInputInterface) => Promise<WalkInterface>;
19
+ export declare const sourceOf: (project: ProjectInterface) => string;
20
+ export declare const projectStatuses: (cwd: string, project: ProjectInterface) => Promise<Map<string, string>>;
21
+ export declare const storyKey: (file: string) => string;
@@ -0,0 +1,104 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { boardStatuses } from './board.js';
4
+ import { ConfigError } from './config.js';
5
+ import { isDirectory, isFile, joinPath, readText } from './features.js';
6
+ import { commonDirectory, currentRepo, git } from './git.js';
7
+ import { sortedText } from './python-text.js';
8
+ const ownBoard = 'tasks/board.yaml';
9
+ const commitSuffix = '^{commit}';
10
+ const splitRevision = (item) => {
11
+ const index = item.indexOf('=');
12
+ return [item.slice(0, index), item.slice(index + 1)];
13
+ };
14
+ export const parseRevisions = async (cwd, projects, items) => {
15
+ const parsed = new Map(items.filter((item) => item.includes('=')).map((item) => splitRevision(item)));
16
+ if (parsed.size !== items.length) {
17
+ throw new ConfigError('--ref ждёт <проект>=<ref>');
18
+ }
19
+ if (!await isDirectory(path.resolve(cwd, projects))) {
20
+ throw new ConfigError(`каталога проектов ${projects} нет`);
21
+ }
22
+ return parsed;
23
+ };
24
+ const projectNames = async (cwd, projects) => {
25
+ const names = await readdir(path.resolve(cwd, projects));
26
+ const flags = await Promise.all(names.map(async (name) => isDirectory(path.resolve(cwd, projects, name))));
27
+ return sortedText(names.filter((_, index) => flags[index] === true));
28
+ };
29
+ const skipNote = (name, commit, revision) => {
30
+ const missing = commit === undefined ? 'ref ' : `${ownBoard} на `;
31
+ return `проект ${name} пропущен — нет ${missing}${revision}`;
32
+ };
33
+ const isText = (text) => text !== undefined && text !== '';
34
+ const projectAtRevision = async (input, source) => {
35
+ const { name, repo } = source;
36
+ const revision = input.revisions.get(name) ?? source.fallback;
37
+ const commit = await git(input.cwd, repo, ['rev-parse', '--verify', '-q', `${revision}${commitSuffix}`]);
38
+ const board = await git(input.cwd, repo, ['show', `${revision}:${ownBoard}`]);
39
+ return !isText(board) || !isText(commit)
40
+ ? skipNote(name, commit, revision)
41
+ : { board, commit: commit.trim(), name, repo, revision };
42
+ };
43
+ const foreignProject = async (input, name) => projectAtRevision(input, { fallback: 'origin/main', name, repo: joinPath(input.projects, name) });
44
+ const hostRoot = async (cwd) => {
45
+ const output = await git(cwd, currentRepo, ['rev-parse', '--show-cdup']);
46
+ const root = output?.trim();
47
+ return root === '' ? undefined : root;
48
+ };
49
+ const ownProject = (name) => ({
50
+ board: undefined,
51
+ commit: undefined,
52
+ name,
53
+ repo: currentRepo,
54
+ revision: undefined,
55
+ });
56
+ const probe = async (input, own, name) => {
57
+ const common = await commonDirectory(input.cwd, joinPath(input.projects, name));
58
+ if (own.common === undefined || common !== own.common) {
59
+ return foreignProject(input, name);
60
+ }
61
+ return own.host === undefined
62
+ ? ownProject(name)
63
+ : projectAtRevision(input, { fallback: 'HEAD', name, repo: own.host });
64
+ };
65
+ const checkRevisions = (input, names) => {
66
+ const unknown = sortedText([...input.revisions.keys()].filter((name) => !names.includes(name)));
67
+ if (unknown.length > 0) {
68
+ throw new ConfigError(`--ref для проектов, которых нет в ${input.projects}: ${unknown.join(', ')}`);
69
+ }
70
+ };
71
+ const isProject = (item) => typeof item === 'object';
72
+ const withOwn = (found, hasOwnBoard) => {
73
+ const isOwnSeen = found.some((project) => project.revision === undefined);
74
+ const projects = found.filter((project) => hasOwnBoard || project.revision !== undefined);
75
+ return isOwnSeen || !hasOwnBoard ? projects : [...projects, ownProject('.')];
76
+ };
77
+ export const walkProjects = async (input) => {
78
+ const own = { common: await commonDirectory(input.cwd, currentRepo), host: await hostRoot(input.cwd) };
79
+ const names = await projectNames(input.cwd, input.projects);
80
+ checkRevisions(input, names);
81
+ const hasOwnBoard = await isFile(input.cwd, ownBoard);
82
+ const probes = await Promise.all(names.map(async (name) => probe(input, own, name)));
83
+ return {
84
+ notes: probes.filter((item) => typeof item === 'string'),
85
+ projects: withOwn(probes.filter((item) => isProject(item)), hasOwnBoard),
86
+ };
87
+ };
88
+ export const sourceOf = (project) => (project.revision === undefined
89
+ ? `${project.name} — рабочее дерево`
90
+ : `${project.name} — ${project.revision} ${(project.commit ?? '').slice(0, 9)}`);
91
+ export const projectStatuses = async (cwd, project) => {
92
+ if (project.revision !== undefined) {
93
+ return boardStatuses(project.board ?? '', `проекта ${project.name} на ${project.revision}`);
94
+ }
95
+ if (await isFile(cwd, 'tasks/status.log')) {
96
+ throw new ConfigError('рядом с board лежит status.log, упразднённый форматом 6: статусы живут в самом board');
97
+ }
98
+ return boardStatuses(await readText(cwd, ownBoard), ownBoard);
99
+ };
100
+ export const storyKey = (file) => {
101
+ const name = path.posix.basename(file);
102
+ const dot = name.lastIndexOf('.');
103
+ return dot > 0 ? name.slice(0, dot) : name;
104
+ };
@@ -0,0 +1,11 @@
1
+ export declare const splitLines: (text: string) => string[];
2
+ export declare const universalNewlines: (text: string) => string;
3
+ export declare const compareText: (left: string, right: string) => number;
4
+ export declare const sortedText: (values: Iterable<string>) => string[];
5
+ export declare const pyRepr: (value: unknown) => string;
6
+ export declare const pyText: (value: unknown) => string;
7
+ export declare const roundTenth: (value: number) => number;
8
+ export declare const capitalize: (text: string) => string;
9
+ export declare const escapeHtml: (text: string) => string;
10
+ export declare const quotePath: (text: string) => string;
11
+ export declare const messageOf: (error: unknown) => string;
@@ -0,0 +1,81 @@
1
+ const breakChars = [0x0A, 0x0B, 0x0C, 0x0D, 0x1C, 0x1D, 0x1E, 0x85, 0x20_28, 0x20_29]
2
+ .map((code) => String.fromCodePoint(code))
3
+ .join('');
4
+ const lineBreak = new RegExp(`\r\n|[${breakChars}]`, 'u');
5
+ const notPrintable = /[\p{C}\p{Z}]/u;
6
+ const urlSafe = /[\w\-./~]/u;
7
+ const namedEscapes = { '\t': String.raw `\t`, '\n': String.raw `\n`, '\r': String.raw `\r` };
8
+ export const splitLines = (text) => {
9
+ const lines = text.split(lineBreak);
10
+ return lines.at(-1) === '' ? lines.slice(0, -1) : lines;
11
+ };
12
+ export const universalNewlines = (text) => text.replaceAll(/\r\n?/gu, '\n');
13
+ const codePoints = (text) => Array.from(text, (char) => char.codePointAt(0) ?? 0);
14
+ export const compareText = (left, right) => {
15
+ const leftPoints = codePoints(left);
16
+ const rightPoints = codePoints(right);
17
+ const index = leftPoints.findIndex((point, position) => point !== rightPoints[position]);
18
+ return index === -1 ? leftPoints.length - rightPoints.length : (leftPoints[index] ?? 0) - (rightPoints[index] ?? -1);
19
+ };
20
+ export const sortedText = (values) => [...values].toSorted(compareText);
21
+ const hexEscape = (char) => {
22
+ const hex = (char.codePointAt(0) ?? 0).toString(16);
23
+ if (hex.length <= 2) {
24
+ return String.raw `\x${hex.padStart(2, '0')}`;
25
+ }
26
+ return hex.length <= 4 ? String.raw `\u${hex.padStart(4, '0')}` : String.raw `\U${hex.padStart(8, '0')}`;
27
+ };
28
+ const escapePrintable = (char) => (char === ' ' || !notPrintable.test(char) ? char : hexEscape(char));
29
+ const escapeQuoted = (char, quote) => {
30
+ const isSpecial = char === quote || char === '\\';
31
+ return isSpecial ? `\\${char}` : escapePrintable(char);
32
+ };
33
+ const reprText = (text) => {
34
+ const quote = text.includes('\'') && !text.includes('"') ? '"' : '\'';
35
+ const body = Array.from(text, (char) => namedEscapes[char] ?? escapeQuoted(char, quote)).join('');
36
+ return `${quote}${body}${quote}`;
37
+ };
38
+ const booleanRepr = (isTrue) => (isTrue ? 'True' : 'False');
39
+ const numberRepr = (value) => (typeof value === 'number' ? String(value) : 'None');
40
+ const reprScalar = (value) => (typeof value === 'boolean' ? booleanRepr(value) : numberRepr(value));
41
+ const reprObject = (value, repr) => {
42
+ if (Array.isArray(value)) {
43
+ return `[${value.map((item) => repr(item)).join(', ')}]`;
44
+ }
45
+ return `{${Object.entries(value).map(([key, item]) => `${reprText(key)}: ${repr(item)}`)
46
+ .join(', ')}}`;
47
+ };
48
+ export const pyRepr = (value) => {
49
+ if (typeof value === 'string') {
50
+ return reprText(value);
51
+ }
52
+ return typeof value === 'object' && value !== null ? reprObject(value, pyRepr) : reprScalar(value);
53
+ };
54
+ export const pyText = (value) => (typeof value === 'string' ? value : pyRepr(value));
55
+ export const roundTenth = (value) => {
56
+ const quarters = value * 4;
57
+ if (Number.isSafeInteger(quarters) && Math.abs(quarters % 2) === 1) {
58
+ const lower = Math.floor(value * 10);
59
+ return (lower % 2 === 0 ? lower : lower + 1) / 10;
60
+ }
61
+ return Number(value.toFixed(1));
62
+ };
63
+ export const capitalize = (text) => {
64
+ const [first = '', ...rest] = [...text];
65
+ return `${first.toUpperCase()}${rest.join('').toLowerCase()}`;
66
+ };
67
+ export const escapeHtml = (text) => text
68
+ .replaceAll('&', '&amp;')
69
+ .replaceAll('<', '&lt;')
70
+ .replaceAll('>', '&gt;')
71
+ .replaceAll('"', '&quot;')
72
+ .replaceAll('\'', '&#x27;');
73
+ const quoteByte = (byte) => `%${byte.toString(16).toUpperCase()
74
+ .padStart(2, '0')}`;
75
+ const quoteBytes = (char) => [...new TextEncoder().encode(char)]
76
+ .map((byte) => quoteByte(byte))
77
+ .join('');
78
+ const quoteChar = (char) => (urlSafe.test(char) ? char : quoteBytes(char));
79
+ export const quotePath = (text) => Array.from(text, (char) => quoteChar(char)).join('');
80
+ const isError = (error) => error instanceof Error;
81
+ export const messageOf = (error) => (isError(error) ? error.message : String(error));
@@ -0,0 +1,6 @@
1
+ export interface BlobLocationInterface {
2
+ readonly commit: string | undefined;
3
+ readonly web: string | undefined;
4
+ }
5
+ export declare const webUrl: (cwd: string, repo: string) => Promise<string | undefined>;
6
+ export declare const blobUrl: (location: BlobLocationInterface, file: string, line?: number) => string | undefined;
@@ -0,0 +1,24 @@
1
+ import { git } from './git.js';
2
+ import { quotePath } from './python-text.js';
3
+ const remoteForms = [
4
+ /^(?<scheme>https?):\/\/(?:[^/@]*@)?(?<host>[^/@]+)\/(?<path>.+?)(?:\.git)?\/?$/u,
5
+ /^ssh:\/\/(?:[^/@]*@)?(?<host>[^/:@]+)(?::\d+)?\/(?<path>.+?)(?:\.git)?\/?$/u,
6
+ /^[^/:@]+@(?<host>[^/:@]+):(?<path>[^/].*?)(?:\.git)?\/?$/u,
7
+ ];
8
+ export const webUrl = async (cwd, repo) => {
9
+ const remote = await git(cwd, repo, ['remote', 'get-url', 'origin']);
10
+ if (remote === undefined) {
11
+ return undefined;
12
+ }
13
+ const address = remote.trim();
14
+ const groups = remoteForms.map((form) => form.exec(address)?.groups).find((found) => found !== undefined);
15
+ return groups === undefined ? undefined : `${groups.scheme ?? 'https'}://${groups.host}/${groups.path}`;
16
+ };
17
+ const hasCommit = (location) => location.commit !== undefined && location.commit !== '';
18
+ export const blobUrl = (location, file, line) => {
19
+ if (location.web === undefined || !hasCommit(location)) {
20
+ return undefined;
21
+ }
22
+ const anchor = line === undefined ? '' : `#L${line}`;
23
+ return `${location.web}/-/blob/${location.commit}/${quotePath(file)}${anchor}`;
24
+ };
@@ -0,0 +1,15 @@
1
+ export interface RemovedListInterface {
2
+ readonly defects: readonly string[];
3
+ readonly entries: ReadonlyMap<string, number>;
4
+ }
5
+ export interface RemovedRowInterface {
6
+ readonly commit: string;
7
+ readonly date: string;
8
+ readonly id: string;
9
+ readonly line: number;
10
+ readonly reason: string;
11
+ readonly replacedBy: string;
12
+ readonly story: string;
13
+ }
14
+ export declare const removedRows: (cwd: string, file: string) => Promise<RemovedRowInterface[]>;
15
+ export declare const parseRemoved: (cwd: string, file: string) => Promise<RemovedListInterface>;
@@ -0,0 +1,57 @@
1
+ import { isFile, readText } from './features.js';
2
+ import { splitLines } from './python-text.js';
3
+ const fieldColumns = [
4
+ ['дата', 'date'],
5
+ ['причина', 'reason'],
6
+ ['story', 'story'],
7
+ ['чем заменён', 'replacedBy'],
8
+ ['коммит', 'commit'],
9
+ ];
10
+ const emptyCells = new Set(['', '-', '—']);
11
+ const separatorCell = /^[ \-:]*$/u;
12
+ const stripChars = (text, chars) => {
13
+ const kept = [...text];
14
+ const start = kept.findIndex((char) => !chars.includes(char));
15
+ const end = kept.findLastIndex((char) => !chars.includes(char));
16
+ return kept.slice(start, end + 1).join('');
17
+ };
18
+ const cellsOf = (line) => stripChars(line.trim(), '|')
19
+ .split('|')
20
+ .map((cell) => stripChars(cell.trim(), '`').trim());
21
+ const cellAt = (cells, index) => cells[index] ?? '';
22
+ const isRecord = (first) => first !== 'id' && !separatorCell.test(first);
23
+ const rowOf = (cells, line) => ({
24
+ commit: cellAt(cells, 5),
25
+ date: cellAt(cells, 1),
26
+ id: cellAt(cells, 0).replace(/^@+/u, ''),
27
+ line,
28
+ reason: cellAt(cells, 2),
29
+ replacedBy: cellAt(cells, 4),
30
+ story: cellAt(cells, 3),
31
+ });
32
+ const recordOf = (line, index) => {
33
+ const cells = cellsOf(line);
34
+ const isRow = line.trimStart().startsWith('|') && isRecord(cellAt(cells, 0));
35
+ return isRow ? [rowOf(cells, index + 1)] : [];
36
+ };
37
+ export const removedRows = async (cwd, file) => {
38
+ if (!await isFile(cwd, file)) {
39
+ return [];
40
+ }
41
+ const text = await readText(cwd, file);
42
+ return splitLines(text).flatMap((line, index) => recordOf(line, index));
43
+ };
44
+ const missingFields = (row) => fieldColumns
45
+ .filter(([, column]) => emptyCells.has(row[column]))
46
+ .map(([name]) => name);
47
+ const rowDefects = (row, file) => {
48
+ const missing = missingFields(row);
49
+ return missing.length > 0 ? [`запись ${row.id} в ${file}:${row.line} без полей: ${missing.join(', ')}`] : [];
50
+ };
51
+ export const parseRemoved = async (cwd, file) => {
52
+ const rows = await removedRows(cwd, file);
53
+ return {
54
+ defects: rows.flatMap((row) => rowDefects(row, file)),
55
+ entries: new Map(rows.map((row) => [row.id, row.line])),
56
+ };
57
+ };
@@ -0,0 +1,6 @@
1
+ export interface ReportInterface {
2
+ readonly defects: readonly string[];
3
+ readonly notes: readonly string[];
4
+ readonly summary: string;
5
+ }
6
+ export declare const formatReport: (report: ReportInterface) => string;
@@ -0,0 +1,6 @@
1
+ export const formatReport = (report) => [
2
+ report.summary,
3
+ ...report.notes.map((note) => `примечание: ${note}`),
4
+ ...report.defects.map((defect) => `дефект: ${defect}`),
5
+ `итого: дефектов ${report.defects.length}`,
6
+ ].map((line) => `${line}\n`).join('');
@@ -0,0 +1,10 @@
1
+ export interface RunContextInterface {
2
+ readonly cwd: string;
3
+ readonly now: Date;
4
+ }
5
+ export interface RunResultInterface {
6
+ readonly code: number;
7
+ readonly stderr: string;
8
+ readonly stdout: string;
9
+ }
10
+ export declare const runScenarios: (argv: readonly string[], context: RunContextInterface) => Promise<RunResultInterface>;