@shahimat/harness 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -2
- package/dist/adapters/memory.d.ts +25 -0
- package/dist/adapters/memory.js +111 -0
- package/dist/adapters/render.d.ts +9 -0
- package/dist/adapters/render.js +43 -0
- package/dist/adapters/source.d.ts +30 -0
- package/dist/adapters/source.js +114 -0
- package/dist/adapters/sync.d.ts +21 -0
- package/dist/adapters/sync.js +182 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/checks/changelog.d.ts +10 -0
- package/dist/checks/changelog.js +88 -0
- package/dist/checks/context-index.d.ts +11 -0
- package/dist/checks/context-index.js +355 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +67 -0
- package/dist/commands/adapters.d.ts +2 -0
- package/dist/commands/adapters.js +18 -0
- package/dist/commands/changelog.d.ts +2 -0
- package/dist/commands/changelog.js +12 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.js +31 -0
- package/dist/commands/context.d.ts +2 -0
- package/dist/commands/context.js +16 -0
- package/dist/commands/prompt-index.d.ts +2 -0
- package/dist/commands/prompt-index.js +48 -0
- package/dist/commands/report.d.ts +3 -0
- package/dist/commands/report.js +12 -0
- package/dist/commands/types.d.ts +8 -0
- package/dist/commands/types.js +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +101 -0
- package/dist/diagnostics.d.ts +12 -0
- package/dist/diagnostics.js +6 -0
- package/dist/frontmatter.d.ts +8 -0
- package/dist/frontmatter.js +24 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/prompt-index.d.ts +49 -0
- package/dist/prompt-index.js +315 -0
- package/package.json +57 -4
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normalizeEol } from '../frontmatter.js';
|
|
4
|
+
export const CHANGELOG_PATH = 'CHANGELOG.md';
|
|
5
|
+
const SEPARATOR = ' · ';
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
function parseIsoDate(value) {
|
|
8
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
9
|
+
if (m === null)
|
|
10
|
+
return null;
|
|
11
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
12
|
+
const time = Date.UTC(y, mo - 1, d);
|
|
13
|
+
const date = new Date(time);
|
|
14
|
+
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d)
|
|
15
|
+
return null;
|
|
16
|
+
return time;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Проверяет журнал решений. Формат записи:
|
|
20
|
+
* `- YYYY-MM-DD · [tag] суть · почему`. До первой записи — свободный
|
|
21
|
+
* текст (заголовок, пояснение), после — только записи и пустые строки.
|
|
22
|
+
*/
|
|
23
|
+
export function lintChangelog(text, options, now, file = CHANGELOG_PATH) {
|
|
24
|
+
const diags = [];
|
|
25
|
+
const latest = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + DAY_MS;
|
|
26
|
+
const tagHint = `[${options.tags.join('|')}]`;
|
|
27
|
+
let seenEntry = false;
|
|
28
|
+
let prev = null;
|
|
29
|
+
normalizeEol(text)
|
|
30
|
+
.split('\n')
|
|
31
|
+
.forEach((line, i) => {
|
|
32
|
+
const report = (message) => {
|
|
33
|
+
diags.push({ file, line: i + 1, message });
|
|
34
|
+
};
|
|
35
|
+
if (line.trim() === '')
|
|
36
|
+
return;
|
|
37
|
+
if (!line.startsWith('- ')) {
|
|
38
|
+
if (seenEntry)
|
|
39
|
+
report('после первой записи допустимы только записи и пустые строки');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
seenEntry = true;
|
|
43
|
+
// Символы, а не UTF-16 и не байты: кириллица не должна считаться вдвое.
|
|
44
|
+
const length = [...line].length;
|
|
45
|
+
if (length > options.maxLine)
|
|
46
|
+
report(`длина ${length} символов > ${options.maxLine}`);
|
|
47
|
+
const parts = line.slice(2).split(SEPARATOR);
|
|
48
|
+
if (parts.length !== 3) {
|
|
49
|
+
report(`ожидается «- YYYY-MM-DD · ${tagHint} суть · почему»: частей через « · » ${parts.length}, нужно 3`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const [date, what, why] = parts;
|
|
53
|
+
const time = parseIsoDate(date);
|
|
54
|
+
if (time === null) {
|
|
55
|
+
report(`дата «${date}» — не YYYY-MM-DD или такого дня нет`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
if (time > latest)
|
|
59
|
+
report(`дата ${date} в будущем`);
|
|
60
|
+
if (prev !== null && time > prev.time)
|
|
61
|
+
report(`порядок: ${date} новее предыдущей записи ${prev.date}`);
|
|
62
|
+
prev = { time, date };
|
|
63
|
+
}
|
|
64
|
+
const tagged = /^\[([^\]]*)\] (.*\S.*)$/.exec(what);
|
|
65
|
+
if (tagged === null) {
|
|
66
|
+
report(`нет тега: суть начинается с ${tagHint}`);
|
|
67
|
+
}
|
|
68
|
+
else if (!options.tags.includes(tagged[1] ?? '')) {
|
|
69
|
+
report(`тег [${tagged[1] ?? ''}] не из списка ${tagHint}`);
|
|
70
|
+
}
|
|
71
|
+
if (why.trim() === '')
|
|
72
|
+
report('пустая часть «почему»');
|
|
73
|
+
});
|
|
74
|
+
return diags;
|
|
75
|
+
}
|
|
76
|
+
export async function checkChangelog(root, options, now) {
|
|
77
|
+
let text;
|
|
78
|
+
try {
|
|
79
|
+
text = await readFile(path.join(root, CHANGELOG_PATH), 'utf8');
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code === 'ENOENT') {
|
|
83
|
+
return [{ file: CHANGELOG_PATH, message: 'файл не найден' }];
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
return lintChangelog(text, options, now);
|
|
88
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Diagnostic } from '../diagnostics.ts';
|
|
2
|
+
export declare const CONTEXT_INDEX_PATH = "context/index.yml";
|
|
3
|
+
export interface ContextIndexResult {
|
|
4
|
+
/** Готовый текст `context/index.yml`. */
|
|
5
|
+
text: string;
|
|
6
|
+
diagnostics: Diagnostic[];
|
|
7
|
+
}
|
|
8
|
+
/** Собирает `context/index.yml` из слоёв документации. */
|
|
9
|
+
export declare function buildContextIndex(root: string): Promise<ContextIndexResult>;
|
|
10
|
+
/** Проверка для `harness check`: нарушения слоёв + индекс совпадает с собранным. */
|
|
11
|
+
export declare function checkContextIndex(root: string): Promise<Diagnostic[]>;
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { access, readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
import { normalizeEol, parseFrontmatter } from '../frontmatter.js';
|
|
5
|
+
export const CONTEXT_INDEX_PATH = 'context/index.yml';
|
|
6
|
+
const PROJECT_PATH = 'context/project.yml';
|
|
7
|
+
const HEADER = '# generated by harness context index — do not edit manually\n';
|
|
8
|
+
const LAYERS = {
|
|
9
|
+
entities: 'docs/entities',
|
|
10
|
+
behaviors: 'docs/behavior',
|
|
11
|
+
manuals: 'docs/manuals',
|
|
12
|
+
conventions: 'context/conventions',
|
|
13
|
+
};
|
|
14
|
+
const MODULES_DIR = 'context/modules';
|
|
15
|
+
const RESERVED_SECTIONS = new Set(['description', 'entities', 'behaviors', 'manuals']);
|
|
16
|
+
/** Первый заголовок `# …` после frontmatter (комментарии YAML не считаются). */
|
|
17
|
+
function firstHeading(text) {
|
|
18
|
+
const lines = normalizeEol(text).split('\n');
|
|
19
|
+
let start = 0;
|
|
20
|
+
if (lines[0] === '---') {
|
|
21
|
+
const end = lines.indexOf('---', 1);
|
|
22
|
+
if (end !== -1)
|
|
23
|
+
start = end + 1;
|
|
24
|
+
}
|
|
25
|
+
const h1 = lines.slice(start).find((line) => line.startsWith('# '));
|
|
26
|
+
return h1 === undefined ? null : h1.slice(2).trim();
|
|
27
|
+
}
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
/** Файлы с расширением `ext` под `dir`, рекурсивно; пути от корня через `/`. */
|
|
32
|
+
async function listFiles(root, dir, ext) {
|
|
33
|
+
let names;
|
|
34
|
+
try {
|
|
35
|
+
names = await readdir(path.join(root, dir), { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code === 'ENOENT')
|
|
39
|
+
return [];
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
return names
|
|
43
|
+
.filter((name) => name.endsWith(ext))
|
|
44
|
+
.map((name) => `${dir}/${name.split(path.sep).join('/')}`)
|
|
45
|
+
.sort();
|
|
46
|
+
}
|
|
47
|
+
const baseName = (file, ext) => path.posix.basename(file, ext);
|
|
48
|
+
/** Имя manual из ссылки: `x`, `x.md` или `docs/manuals/x.md` → `x`. */
|
|
49
|
+
const manualName = (ref) => path.posix.basename(ref, '.md');
|
|
50
|
+
class Collector {
|
|
51
|
+
diagnostics = [];
|
|
52
|
+
namespaces = new Map();
|
|
53
|
+
conventions = new Map();
|
|
54
|
+
/** `null` — project.yml нет, namespace не проверяются. */
|
|
55
|
+
declared = null;
|
|
56
|
+
root;
|
|
57
|
+
constructor(root) {
|
|
58
|
+
this.root = root;
|
|
59
|
+
}
|
|
60
|
+
report(file, message) {
|
|
61
|
+
this.diagnostics.push({ file, message });
|
|
62
|
+
}
|
|
63
|
+
async readYaml(file) {
|
|
64
|
+
let data;
|
|
65
|
+
try {
|
|
66
|
+
data = parse(await readFile(path.join(this.root, file), 'utf8'));
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
this.report(file, `невалидный YAML: ${error.message.split('\n')[0]}`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (!isRecord(data)) {
|
|
73
|
+
this.report(file, 'корень должен быть объектом');
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
namespace(name) {
|
|
79
|
+
let ns = this.namespaces.get(name);
|
|
80
|
+
if (ns === undefined) {
|
|
81
|
+
ns = { entities: new Map(), behaviors: new Map(), manuals: new Map(), layers: new Map() };
|
|
82
|
+
this.namespaces.set(name, ns);
|
|
83
|
+
}
|
|
84
|
+
return ns;
|
|
85
|
+
}
|
|
86
|
+
/** Однострочный текст поля; нет или многострочный — нарушение и `null`. */
|
|
87
|
+
oneLine(file, data, field) {
|
|
88
|
+
const value = data[field];
|
|
89
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
90
|
+
this.report(file, `нет поля ${field} (одна строка для индекса)`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (value.trim().includes('\n')) {
|
|
94
|
+
this.report(file, `${field} должно быть одной строкой`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return value.trim();
|
|
98
|
+
}
|
|
99
|
+
checkId(file, data, field, ext) {
|
|
100
|
+
const expected = baseName(file, ext);
|
|
101
|
+
const id = data[field];
|
|
102
|
+
if (typeof id !== 'string' || id === '') {
|
|
103
|
+
this.report(file, `нет поля ${field}`);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (id !== expected)
|
|
107
|
+
this.report(file, `${field} «${id}» не совпадает с именем файла «${expected}»`);
|
|
108
|
+
return id;
|
|
109
|
+
}
|
|
110
|
+
checkNamespace(file, data) {
|
|
111
|
+
const ns = data['namespace'];
|
|
112
|
+
if (typeof ns !== 'string' || ns === '') {
|
|
113
|
+
this.report(file, 'нет поля namespace');
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (this.declared !== null && !this.declared.has(ns)) {
|
|
117
|
+
this.report(file, `namespace «${ns}» не объявлен в ${PROJECT_PATH} → namespaces`);
|
|
118
|
+
}
|
|
119
|
+
return ns;
|
|
120
|
+
}
|
|
121
|
+
async loadProject() {
|
|
122
|
+
try {
|
|
123
|
+
await access(path.join(this.root, PROJECT_PATH));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
this.report(PROJECT_PATH, 'файл не найден: namespace не проверяются');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const data = await this.readYaml(PROJECT_PATH);
|
|
130
|
+
if (data === null)
|
|
131
|
+
return;
|
|
132
|
+
const namespaces = data['namespaces'];
|
|
133
|
+
if (!isRecord(namespaces)) {
|
|
134
|
+
this.report(PROJECT_PATH, 'нет раздела namespaces');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.declared = new Set(Object.keys(namespaces));
|
|
138
|
+
for (const [name, description] of Object.entries(namespaces)) {
|
|
139
|
+
const ns = this.namespace(name);
|
|
140
|
+
if (typeof description === 'string' && description.trim() !== '')
|
|
141
|
+
ns.description = description.trim();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async collect() {
|
|
145
|
+
await this.loadProject();
|
|
146
|
+
const manualRefs = new Map();
|
|
147
|
+
for (const file of await listFiles(this.root, LAYERS.entities, '.yml')) {
|
|
148
|
+
const data = await this.readYaml(file);
|
|
149
|
+
if (data === null)
|
|
150
|
+
continue;
|
|
151
|
+
const id = this.checkId(file, data, 'id', '.yml');
|
|
152
|
+
const ns = this.checkNamespace(file, data);
|
|
153
|
+
const summary = this.oneLine(file, data, 'summary');
|
|
154
|
+
if (id === null || ns === null)
|
|
155
|
+
continue;
|
|
156
|
+
this.namespace(ns).entities.set(id, summary);
|
|
157
|
+
const manuals = data['manuals'];
|
|
158
|
+
if (Array.isArray(manuals)) {
|
|
159
|
+
for (const ref of manuals) {
|
|
160
|
+
if (typeof ref !== 'string')
|
|
161
|
+
continue;
|
|
162
|
+
const name = manualName(ref);
|
|
163
|
+
manualRefs.set(name, (manualRefs.get(name) ?? new Set()).add(ns));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const file of await listFiles(this.root, LAYERS.behaviors, '.yml')) {
|
|
168
|
+
const data = await this.readYaml(file);
|
|
169
|
+
if (data === null)
|
|
170
|
+
continue;
|
|
171
|
+
const id = this.checkId(file, data, 'id', '.yml');
|
|
172
|
+
const ns = this.checkNamespace(file, data);
|
|
173
|
+
const title = this.oneLine(file, data, 'title');
|
|
174
|
+
if (id === null || ns === null)
|
|
175
|
+
continue;
|
|
176
|
+
this.namespace(ns).behaviors.set(id, title);
|
|
177
|
+
}
|
|
178
|
+
for (const file of await listFiles(this.root, LAYERS.conventions, '.yml')) {
|
|
179
|
+
const data = await this.readYaml(file);
|
|
180
|
+
if (data === null)
|
|
181
|
+
continue;
|
|
182
|
+
const key = this.checkId(file, data, 'key', '.yml');
|
|
183
|
+
const summary = this.oneLine(file, data, 'summary');
|
|
184
|
+
if (key === null)
|
|
185
|
+
continue;
|
|
186
|
+
if (this.conventions.has(key))
|
|
187
|
+
this.report(file, `key «${key}» уже занят другой convention`);
|
|
188
|
+
this.conventions.set(key, summary);
|
|
189
|
+
}
|
|
190
|
+
for (const file of await listFiles(this.root, LAYERS.manuals, '.md')) {
|
|
191
|
+
const name = baseName(file, '.md');
|
|
192
|
+
const title = firstHeading(await readFile(path.join(this.root, file), 'utf8'));
|
|
193
|
+
if (title === null)
|
|
194
|
+
this.report(file, 'нет заголовка «# …» (одна строка для индекса)');
|
|
195
|
+
const owners = manualRefs.get(name);
|
|
196
|
+
if (owners === undefined) {
|
|
197
|
+
this.report(file, 'manual не упомянут ни в одной entity (поле manuals) — namespace не определить');
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
for (const ns of owners)
|
|
201
|
+
this.namespace(ns).manuals.set(name, title);
|
|
202
|
+
}
|
|
203
|
+
for (const layer of await this.loadModuleLayers())
|
|
204
|
+
await this.collectLayer(layer);
|
|
205
|
+
}
|
|
206
|
+
/** Слои, которые модули объявили в `provides.index_layers`. */
|
|
207
|
+
async loadModuleLayers() {
|
|
208
|
+
const layers = [];
|
|
209
|
+
for (const manifest of await listFiles(this.root, MODULES_DIR, '.yml')) {
|
|
210
|
+
const data = await this.readYaml(manifest);
|
|
211
|
+
if (data === null)
|
|
212
|
+
continue;
|
|
213
|
+
const provides = data['provides'];
|
|
214
|
+
const declared = isRecord(provides) ? provides['index_layers'] : undefined;
|
|
215
|
+
if (declared === undefined || declared === null)
|
|
216
|
+
continue;
|
|
217
|
+
if (!Array.isArray(declared)) {
|
|
218
|
+
this.report(manifest, 'provides.index_layers должно быть списком');
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
for (const item of declared) {
|
|
222
|
+
const section = isRecord(item) ? item['section'] : undefined;
|
|
223
|
+
const dir = isRecord(item) ? item['dir'] : undefined;
|
|
224
|
+
if (typeof section !== 'string' || !/^[a-z][a-z0-9-]*$/.test(section) || RESERVED_SECTIONS.has(section)) {
|
|
225
|
+
this.report(manifest, `index_layers: section «${String(section)}» — нужен kebab-case, не ${[...RESERVED_SECTIONS].join('/')}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (typeof dir !== 'string' || dir === '' || path.isAbsolute(dir) || dir.split('/').includes('..')) {
|
|
229
|
+
this.report(manifest, `index_layers: dir «${String(dir)}» — нужен путь внутри проекта`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const format = (isRecord(item) ? item['format'] : undefined) ?? 'md';
|
|
233
|
+
if (format !== 'md' && format !== 'yml') {
|
|
234
|
+
this.report(manifest, `index_layers: format «${String(format)}» — md или yml`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const taken = layers.find((l) => l.section === section);
|
|
238
|
+
if (taken !== undefined) {
|
|
239
|
+
this.report(manifest, `index_layers: section «${section}» уже объявлена в ${taken.manifest}`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
layers.push({ section, dir: dir.replace(/\/+$/, ''), format, manifest });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return layers;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Файл слоя модуля: `md` — frontmatter `id`, `namespace`, строка индекса —
|
|
249
|
+
* первый `# …`; `yml` — поля `id`, `namespace`, `summary`. `id` = путь
|
|
250
|
+
* внутри слоя без расширения, `/` → `-`.
|
|
251
|
+
*/
|
|
252
|
+
async collectLayer(layer) {
|
|
253
|
+
const ext = layer.format === 'md' ? '.md' : '.yml';
|
|
254
|
+
for (const file of await listFiles(this.root, layer.dir, ext)) {
|
|
255
|
+
let data;
|
|
256
|
+
let title;
|
|
257
|
+
if (layer.format === 'yml') {
|
|
258
|
+
data = await this.readYaml(file);
|
|
259
|
+
if (data === null)
|
|
260
|
+
continue;
|
|
261
|
+
title = this.oneLine(file, data, 'summary');
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const text = await readFile(path.join(this.root, file), 'utf8');
|
|
265
|
+
try {
|
|
266
|
+
data = parseFrontmatter(text);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
this.report(file, `невалидный frontmatter: ${error.message.split('\n')[0]}`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (data === null) {
|
|
273
|
+
this.report(file, 'нет frontmatter (id, namespace)');
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
title = firstHeading(text);
|
|
277
|
+
if (title === null)
|
|
278
|
+
this.report(file, 'нет заголовка «# …» (одна строка для индекса)');
|
|
279
|
+
}
|
|
280
|
+
const expected = file.slice(layer.dir.length + 1, -ext.length).split('/').join('-');
|
|
281
|
+
const id = data['id'];
|
|
282
|
+
if (typeof id !== 'string' || id === '') {
|
|
283
|
+
this.report(file, 'нет поля id');
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (id !== expected)
|
|
287
|
+
this.report(file, `id «${id}» не совпадает с путём «${expected}»`);
|
|
288
|
+
const ns = this.checkNamespace(file, data);
|
|
289
|
+
if (ns === null)
|
|
290
|
+
continue;
|
|
291
|
+
const sections = this.namespace(ns).layers;
|
|
292
|
+
const entries = sections.get(layer.section) ?? new Map();
|
|
293
|
+
if (entries.has(id))
|
|
294
|
+
this.report(file, `id «${id}» уже занят в слое ${layer.section}`);
|
|
295
|
+
entries.set(id, title);
|
|
296
|
+
sections.set(layer.section, entries);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const sorted = (map) => Object.fromEntries([...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
301
|
+
function render(collector) {
|
|
302
|
+
const namespaces = {};
|
|
303
|
+
// Порядок namespace — как в project.yml; необъявленные — следом по алфавиту.
|
|
304
|
+
const declared = [...collector.namespaces.keys()].filter((n) => collector.declared?.has(n));
|
|
305
|
+
const extra = [...collector.namespaces.keys()].filter((n) => !collector.declared?.has(n)).sort();
|
|
306
|
+
for (const name of [...declared, ...extra]) {
|
|
307
|
+
const ns = collector.namespaces.get(name);
|
|
308
|
+
if (ns === undefined)
|
|
309
|
+
continue;
|
|
310
|
+
const out = {};
|
|
311
|
+
if (ns.description !== undefined)
|
|
312
|
+
out['description'] = ns.description;
|
|
313
|
+
for (const section of ['entities', 'behaviors', 'manuals']) {
|
|
314
|
+
if (ns[section].size > 0)
|
|
315
|
+
out[section] = sorted(ns[section]);
|
|
316
|
+
}
|
|
317
|
+
for (const section of [...ns.layers.keys()].sort()) {
|
|
318
|
+
const entries = ns.layers.get(section);
|
|
319
|
+
if (entries !== undefined && entries.size > 0)
|
|
320
|
+
out[section] = sorted(entries);
|
|
321
|
+
}
|
|
322
|
+
namespaces[name] = out;
|
|
323
|
+
}
|
|
324
|
+
const doc = {};
|
|
325
|
+
if (collector.conventions.size > 0)
|
|
326
|
+
doc['conventions'] = sorted(collector.conventions);
|
|
327
|
+
if (Object.keys(namespaces).length > 0)
|
|
328
|
+
doc['namespaces'] = namespaces;
|
|
329
|
+
return HEADER + stringify(doc, { lineWidth: 0 });
|
|
330
|
+
}
|
|
331
|
+
/** Собирает `context/index.yml` из слоёв документации. */
|
|
332
|
+
export async function buildContextIndex(root) {
|
|
333
|
+
const collector = new Collector(root);
|
|
334
|
+
await collector.collect();
|
|
335
|
+
return { text: render(collector), diagnostics: collector.diagnostics };
|
|
336
|
+
}
|
|
337
|
+
/** Проверка для `harness check`: нарушения слоёв + индекс совпадает с собранным. */
|
|
338
|
+
export async function checkContextIndex(root) {
|
|
339
|
+
const { text, diagnostics } = await buildContextIndex(root);
|
|
340
|
+
let actual = null;
|
|
341
|
+
try {
|
|
342
|
+
actual = await readFile(path.join(root, CONTEXT_INDEX_PATH), 'utf8');
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (error.code !== 'ENOENT')
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
if (actual === null) {
|
|
349
|
+
diagnostics.push({ file: CONTEXT_INDEX_PATH, message: 'файл не найден: запусти harness context index' });
|
|
350
|
+
}
|
|
351
|
+
else if (normalizeEol(actual) !== text) {
|
|
352
|
+
diagnostics.push({ file: CONTEXT_INDEX_PATH, message: 'устарел: запусти harness context index' });
|
|
353
|
+
}
|
|
354
|
+
return diagnostics;
|
|
355
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { runAdapters } from './commands/adapters.js';
|
|
4
|
+
import { runChangelog } from './commands/changelog.js';
|
|
5
|
+
import { runCheck } from './commands/check.js';
|
|
6
|
+
import { runContext } from './commands/context.js';
|
|
7
|
+
import { runIndex } from './commands/prompt-index.js';
|
|
8
|
+
import { UsageError } from './diagnostics.js';
|
|
9
|
+
const USAGE = `Использование:
|
|
10
|
+
harness check [--root <dir>]
|
|
11
|
+
harness adapters sync [--root <dir>]
|
|
12
|
+
harness changelog lint [--root <dir>]
|
|
13
|
+
harness context index [--root <dir>]
|
|
14
|
+
harness index build --raw-base <url> [--root <dir>] [--out <file>]
|
|
15
|
+
harness index check --raw-base <url> [--root <dir>]
|
|
16
|
+
|
|
17
|
+
Коды выхода: 0 — ok, 1 — найдены нарушения, 2 — ошибка вызова или конфигурации.`;
|
|
18
|
+
export async function main(argv, now = new Date()) {
|
|
19
|
+
const { values, positionals } = parseArgs({
|
|
20
|
+
args: argv,
|
|
21
|
+
allowPositionals: true,
|
|
22
|
+
options: {
|
|
23
|
+
root: { type: 'string', default: '.' },
|
|
24
|
+
out: { type: 'string' },
|
|
25
|
+
'raw-base': { type: 'string' },
|
|
26
|
+
help: { type: 'boolean', short: 'h' },
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
if (values.help) {
|
|
30
|
+
console.log(USAGE);
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
const args = { root: path.resolve(values.root), now };
|
|
34
|
+
if (values.out !== undefined)
|
|
35
|
+
args.out = values.out;
|
|
36
|
+
if (values['raw-base'] !== undefined)
|
|
37
|
+
args.rawBase = values['raw-base'];
|
|
38
|
+
const [group, command] = positionals;
|
|
39
|
+
switch (group) {
|
|
40
|
+
case 'adapters':
|
|
41
|
+
return runAdapters(command, args);
|
|
42
|
+
case 'check':
|
|
43
|
+
return runCheck(args);
|
|
44
|
+
case 'changelog':
|
|
45
|
+
return runChangelog(command, args);
|
|
46
|
+
case 'context':
|
|
47
|
+
return runContext(command, args);
|
|
48
|
+
case 'index':
|
|
49
|
+
return runIndex(command, args);
|
|
50
|
+
default:
|
|
51
|
+
throw new UsageError(`неизвестная команда: ${positionals.join(' ') || '<пусто>'}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function run(argv) {
|
|
55
|
+
try {
|
|
56
|
+
return await main(argv);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
|
60
|
+
if (error instanceof UsageError) {
|
|
61
|
+
console.error(USAGE);
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
// parseArgs бросает TypeError с code ERR_PARSE_ARGS_* на неизвестных опциях.
|
|
65
|
+
return String(error.code).startsWith('ERR_PARSE_ARGS') ? 2 : 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { applyActions, computeSync } from '../adapters/sync.js';
|
|
2
|
+
import { CONFIG_PATH, loadConfig } from '../config.js';
|
|
3
|
+
import { UsageError } from '../diagnostics.js';
|
|
4
|
+
import { report } from './report.js';
|
|
5
|
+
export async function runAdapters(command, args) {
|
|
6
|
+
if (command !== 'sync')
|
|
7
|
+
throw new UsageError(`неизвестная команда: adapters ${command ?? ''}`.trimEnd());
|
|
8
|
+
const config = await loadConfig(args.root);
|
|
9
|
+
if (config === null)
|
|
10
|
+
throw new UsageError(`нет ${CONFIG_PATH}: список adapters берётся оттуда`);
|
|
11
|
+
const { actions, diagnostics } = await computeSync(args.root, config.adapters);
|
|
12
|
+
await applyActions(args.root, actions);
|
|
13
|
+
for (const action of actions)
|
|
14
|
+
console.error(`${action.op === 'write' ? 'wrote' : 'deleted'} ${action.path}`);
|
|
15
|
+
if (actions.length === 0)
|
|
16
|
+
console.error('adapters: без изменений');
|
|
17
|
+
return report('adapters', diagnostics);
|
|
18
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { checkChangelog } from '../checks/changelog.js';
|
|
2
|
+
import { DEFAULT_CHANGELOG, loadConfig } from '../config.js';
|
|
3
|
+
import { UsageError } from '../diagnostics.js';
|
|
4
|
+
import { report } from './report.js';
|
|
5
|
+
export async function runChangelog(command, args) {
|
|
6
|
+
if (command !== 'lint')
|
|
7
|
+
throw new UsageError(`неизвестная команда: changelog ${command ?? ''}`.trimEnd());
|
|
8
|
+
// Работает и без harness.yml — со значениями по умолчанию.
|
|
9
|
+
const config = await loadConfig(args.root);
|
|
10
|
+
const options = config?.checks.changelog ?? DEFAULT_CHANGELOG;
|
|
11
|
+
return report('changelog', await checkChangelog(args.root, options, args.now));
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { checkAdapters } from '../adapters/sync.js';
|
|
2
|
+
import { checkChangelog } from '../checks/changelog.js';
|
|
3
|
+
import { checkContextIndex } from '../checks/context-index.js';
|
|
4
|
+
import { CONFIG_PATH, loadConfig } from '../config.js';
|
|
5
|
+
import { UsageError } from '../diagnostics.js';
|
|
6
|
+
import { report } from './report.js';
|
|
7
|
+
/** Запускает все проверки, включённые в harness.yml. */
|
|
8
|
+
export async function runCheck(args) {
|
|
9
|
+
const config = await loadConfig(args.root);
|
|
10
|
+
if (config === null)
|
|
11
|
+
throw new UsageError(`нет ${CONFIG_PATH}: harness в проекте не настроен`);
|
|
12
|
+
const ran = [];
|
|
13
|
+
const diags = [];
|
|
14
|
+
if (config.checks.changelog !== undefined) {
|
|
15
|
+
ran.push('changelog');
|
|
16
|
+
diags.push(...(await checkChangelog(args.root, config.checks.changelog, args.now)));
|
|
17
|
+
}
|
|
18
|
+
if (config.checks['context-index'] !== undefined) {
|
|
19
|
+
ran.push('context-index');
|
|
20
|
+
diags.push(...(await checkContextIndex(args.root)));
|
|
21
|
+
}
|
|
22
|
+
if (config.checks.adapters !== undefined) {
|
|
23
|
+
ran.push('adapters');
|
|
24
|
+
diags.push(...(await checkAdapters(args.root, config.adapters)));
|
|
25
|
+
}
|
|
26
|
+
if (ran.length === 0) {
|
|
27
|
+
console.log(`check: в ${CONFIG_PATH} не включено ни одной проверки`);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
return report(`check (${ran.join(', ')})`, diags);
|
|
31
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildContextIndex, CONTEXT_INDEX_PATH } from '../checks/context-index.js';
|
|
4
|
+
import { UsageError } from '../diagnostics.js';
|
|
5
|
+
import { report } from './report.js';
|
|
6
|
+
export async function runContext(command, args) {
|
|
7
|
+
if (command !== 'index')
|
|
8
|
+
throw new UsageError(`неизвестная команда: context ${command ?? ''}`.trimEnd());
|
|
9
|
+
const { text, diagnostics } = await buildContextIndex(args.root);
|
|
10
|
+
// Пишем и при нарушениях: индекс с пробелами полезнее устаревшего.
|
|
11
|
+
const file = path.join(args.root, CONTEXT_INDEX_PATH);
|
|
12
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
13
|
+
await writeFile(file, text, 'utf8');
|
|
14
|
+
console.error(`wrote ${CONTEXT_INDEX_PATH}`);
|
|
15
|
+
return report('context index', diagnostics);
|
|
16
|
+
}
|