@shahimat/harness 0.1.0 → 0.3.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.
@@ -0,0 +1,5 @@
1
+ /** Ссылка на доказательство: `path` или `path#Заголовок` (раздел markdown). */
2
+ export declare function readEvidence(ref: string): Promise<string>;
3
+ export declare const shaOf: (text: string) => string;
4
+ /** Сравнение цитаты с текстом без учёта пробелов, переносов и маркеров markdown. */
5
+ export declare function containsQuote(text: string, quote: string): boolean;
@@ -0,0 +1,21 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readRepo, extractSection } from './repo.js';
3
+ /** Ссылка на доказательство: `path` или `path#Заголовок` (раздел markdown). */
4
+ export async function readEvidence(ref) {
5
+ const hash = ref.indexOf('#');
6
+ const file = hash === -1 ? ref : ref.slice(0, hash);
7
+ const text = await readRepo(file);
8
+ if (hash === -1)
9
+ return text;
10
+ const section = extractSection(text, ref.slice(hash + 1));
11
+ if (section === null)
12
+ throw new Error(`${ref}: раздел не найден`);
13
+ return section;
14
+ }
15
+ export const shaOf = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
16
+ /** Сравнение цитаты с текстом без учёта пробелов, переносов и маркеров markdown. */
17
+ export function containsQuote(text, quote) {
18
+ const norm = (s) => s.replace(/[`*_>]/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
19
+ const q = norm(quote);
20
+ return q.length >= 8 && norm(text).includes(q);
21
+ }
@@ -0,0 +1,4 @@
1
+ export { judged } from './judge.ts';
2
+ export { codeBlock, contract, doc, extractSection, indexEntries, module, modules, presets, readRepo, repoRoot, skill, skills, type ContractDoc, type Doc, type ModuleDoc, type PresetDoc, type SkillDoc, } from './repo.ts';
3
+ export { dataFiles, expectSchema, frontmatters, schemaErrors, type Instance } from './schema.ts';
4
+ export { budget, crit, expectAtMost, note, warn, type Budget, type Level } from './levels.ts';
@@ -0,0 +1,4 @@
1
+ export { judged } from './judge.js';
2
+ export { codeBlock, contract, doc, extractSection, indexEntries, module, modules, presets, readRepo, repoRoot, skill, skills, } from './repo.js';
3
+ export { dataFiles, expectSchema, frontmatters, schemaErrors } from './schema.js';
4
+ export { budget, crit, expectAtMost, note, warn } from './levels.js';
@@ -0,0 +1,24 @@
1
+ /** Каталог, куда в режиме сбора пишутся ожидания без свежего вердикта. */
2
+ export declare const COLLECT_ENV = "HARNESS_EXPECT_COLLECT";
3
+ /** Ожидание, которому нужен вердикт судьи. */
4
+ export interface PendingItem {
5
+ id: string;
6
+ question: string;
7
+ evidence: {
8
+ ref: string;
9
+ sha: string;
10
+ bytes: number;
11
+ }[];
12
+ }
13
+ /**
14
+ * Ключ вердикта: заголовок + доказательства. Один и тот же набор ожиданий
15
+ * (например, из @shahimat/expectations) проверяет разные файлы — вердикты
16
+ * не должны затирать друг друга.
17
+ */
18
+ export declare const idOf: (question: string, refs?: string[]) => string;
19
+ /**
20
+ * Смысловая проверка: вопрос — заголовок текущего `it`, доказательства —
21
+ * файлы или разделы (`path#Заголовок`). Вердикт берётся из кэша; нет
22
+ * свежего — тест падает с подсказкой `harness expect judge`.
23
+ */
24
+ export declare function judged(evidence: string[]): Promise<void>;
@@ -0,0 +1,50 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { expect } from 'vitest';
4
+ import { readEvidence, shaOf } from './evidence.js';
5
+ import { loadVerdicts } from './verdicts.js';
6
+ /** Каталог, куда в режиме сбора пишутся ожидания без свежего вердикта. */
7
+ export const COLLECT_ENV = 'HARNESS_EXPECT_COLLECT';
8
+ /**
9
+ * Ключ вердикта: заголовок + доказательства. Один и тот же набор ожиданий
10
+ * (например, из @shahimat/expectations) проверяет разные файлы — вердикты
11
+ * не должны затирать друг друга.
12
+ */
13
+ export const idOf = (question, refs = []) => shaOf([question, ...[...refs].sort()].join('\n')).slice(0, 12);
14
+ let store = null;
15
+ /**
16
+ * Смысловая проверка: вопрос — заголовок текущего `it`, доказательства —
17
+ * файлы или разделы (`path#Заголовок`). Вердикт берётся из кэша; нет
18
+ * свежего — тест падает с подсказкой `harness expect judge`.
19
+ */
20
+ export async function judged(evidence) {
21
+ const question = expect.getState().currentTestName ?? '';
22
+ if (question === '')
23
+ throw new Error('judged() вызывается только внутри it()');
24
+ if (evidence.length === 0)
25
+ throw new Error('judged(): нужен хотя бы один файл-доказательство');
26
+ const id = idOf(question, evidence);
27
+ const refs = await Promise.all(evidence.map(async (ref) => {
28
+ const text = await readEvidence(ref);
29
+ return { ref, sha: shaOf(text), bytes: Buffer.byteLength(text, 'utf8') };
30
+ }));
31
+ store ??= loadVerdicts();
32
+ const cached = (await store).verdicts[id];
33
+ const fresh = cached !== undefined &&
34
+ cached.question === question &&
35
+ Object.keys(cached.evidence).length === refs.length &&
36
+ refs.every((r) => cached.evidence[r.ref] === r.sha);
37
+ if (!fresh) {
38
+ const dir = process.env[COLLECT_ENV];
39
+ if (dir !== undefined) {
40
+ await mkdir(dir, { recursive: true });
41
+ const item = { id, question, evidence: refs };
42
+ await writeFile(path.join(dir, `${id}.json`), JSON.stringify(item), 'utf8');
43
+ return;
44
+ }
45
+ throw new Error(`нет свежего вердикта судьи — запусти harness expect judge [${id}]`);
46
+ }
47
+ if (!cached.verdict) {
48
+ throw new Error(`судья: нет — ${cached.reason} (${cached.ref}: «${cached.quote}»)`);
49
+ }
50
+ }
@@ -0,0 +1,25 @@
1
+ /** Уровень ожидания: crit и warn считаются в бюджет файла, info — никогда. */
2
+ export type Level = 'crit' | 'warn' | 'info';
3
+ export interface Budget {
4
+ crit: number;
5
+ warn: number;
6
+ }
7
+ /** Заголовок служебного теста с бюджетом файла; в отчёт не попадает. */
8
+ export declare const BUDGET_TITLE = "__budget__";
9
+ /** Доля порога, после которой формальная метрика оставляет info. */
10
+ export declare const NEAR_LIMIT = 0.9;
11
+ type Body = () => unknown;
12
+ /** Ожидание уровня crit: формальная метрика, нарушение блокирует при бюджете 0. */
13
+ export declare const crit: (title: string, fn: Body) => void;
14
+ /** Ожидание уровня warn: как правило — проверка судьёй. */
15
+ export declare const warn: (title: string, fn: Body) => void;
16
+ /** Пояснение для исполнителя в отчёте (уровень info, не блокирует). */
17
+ export declare function note(text: string): void;
18
+ /** Бюджет файла: сколько нарушений crit и warn допустимо. По умолчанию 0 / 0. */
19
+ export declare function budget(value: Budget): void;
20
+ /**
21
+ * Числовой порог: больше `limit` — нарушение; больше 90% порога —
22
+ * info «близко к порогу».
23
+ */
24
+ export declare function expectAtMost(value: number, limit: number, label: string): void;
25
+ export {};
@@ -0,0 +1,43 @@
1
+ import { expect, it } from 'vitest';
2
+ /** Заголовок служебного теста с бюджетом файла; в отчёт не попадает. */
3
+ export const BUDGET_TITLE = '__budget__';
4
+ /** Доля порога, после которой формальная метрика оставляет info. */
5
+ export const NEAR_LIMIT = 0.9;
6
+ let notes = [];
7
+ function levelled(level, title, fn) {
8
+ it(title, async (ctx) => {
9
+ const meta = ctx.task.meta;
10
+ meta['level'] = level;
11
+ notes = [];
12
+ try {
13
+ await fn();
14
+ }
15
+ finally {
16
+ meta['notes'] = notes;
17
+ notes = [];
18
+ }
19
+ });
20
+ }
21
+ /** Ожидание уровня crit: формальная метрика, нарушение блокирует при бюджете 0. */
22
+ export const crit = (title, fn) => levelled('crit', title, fn);
23
+ /** Ожидание уровня warn: как правило — проверка судьёй. */
24
+ export const warn = (title, fn) => levelled('warn', title, fn);
25
+ /** Пояснение для исполнителя в отчёте (уровень info, не блокирует). */
26
+ export function note(text) {
27
+ notes.push(text);
28
+ }
29
+ /** Бюджет файла: сколько нарушений crit и warn допустимо. По умолчанию 0 / 0. */
30
+ export function budget(value) {
31
+ it(BUDGET_TITLE, (ctx) => {
32
+ ctx.task.meta['budget'] = value;
33
+ });
34
+ }
35
+ /**
36
+ * Числовой порог: больше `limit` — нарушение; больше 90% порога —
37
+ * info «близко к порогу».
38
+ */
39
+ export function expectAtMost(value, limit, label) {
40
+ if (value > limit * NEAR_LIMIT && value <= limit)
41
+ note(`${label}: ${value} из ${limit} — ${Math.round((value / limit) * 100)}% порога`);
42
+ expect(value, label).toBeLessThanOrEqual(limit);
43
+ }
@@ -0,0 +1,15 @@
1
+ /** Ожидание — заголовок `it` с путём `describe`. */
2
+ export interface Title {
3
+ file: string;
4
+ path: string[];
5
+ title: string;
6
+ }
7
+ export declare const MAX_TITLE = 200;
8
+ export interface TitleIssue {
9
+ title: Title;
10
+ message: string;
11
+ /** Длина и дубли — crit; размытые слова — warn. */
12
+ level: 'crit' | 'warn';
13
+ }
14
+ /** Механическая проверка заголовков: длина, размытые слова, дубли. */
15
+ export declare function lintTitles(titles: Title[]): TitleIssue[];
@@ -0,0 +1,38 @@
1
+ export const MAX_TITLE = 200;
2
+ /** Слова, после которых ожидание нельзя проверить «да/нет» или числом. */
3
+ const VAGUE = [
4
+ 'удобно',
5
+ 'удобный',
6
+ 'быстро',
7
+ 'достаточно',
8
+ 'при необходимости',
9
+ 'по возможности',
10
+ 'и т.п',
11
+ 'и т.д',
12
+ 'стараться',
13
+ 'желательно',
14
+ 'хорошо',
15
+ 'красиво',
16
+ 'понятно',
17
+ 'оптимально',
18
+ 'как можно',
19
+ ];
20
+ /** Механическая проверка заголовков: длина, размытые слова, дубли. */
21
+ export function lintTitles(titles) {
22
+ const out = [];
23
+ const seen = new Set();
24
+ for (const t of titles) {
25
+ const full = `${t.file} :: ${[...t.path, t.title].join(' > ')}`;
26
+ const length = [...t.title].length;
27
+ if (length > MAX_TITLE)
28
+ out.push({ title: t, message: `длина ${length} > ${MAX_TITLE} символов — раздели ожидание`, level: 'crit' });
29
+ const lower = t.title.toLowerCase();
30
+ const vague = VAGUE.filter((w) => new RegExp(`(^|[^а-яё])${w.replace('.', '\\.')}($|[^а-яё])`).test(lower));
31
+ if (vague.length > 0)
32
+ out.push({ title: t, message: `размыто: ${vague.join(', ')} — нужен порог, число или да/нет`, level: 'warn' });
33
+ if (seen.has(full))
34
+ out.push({ title: t, message: 'дубль', level: 'crit' });
35
+ seen.add(full);
36
+ }
37
+ return out;
38
+ }
@@ -0,0 +1,60 @@
1
+ import { type Frontmatter } from '../frontmatter.ts';
2
+ import { type IndexEntry } from '../prompt-index.ts';
3
+ /** Корень репозитория для зондов: HARNESS_EXPECT_ROOT или cwd. */
4
+ export declare function repoRoot(): string;
5
+ export declare function readRepo(rel: string): Promise<string>;
6
+ /** Markdown-документ репозитория с удобными зондами. */
7
+ export interface Doc {
8
+ path: string;
9
+ text: string;
10
+ frontmatter: Frontmatter | null;
11
+ lines: number;
12
+ bytes: number;
13
+ headings: string[];
14
+ /** Текст раздела с заголовком `heading` (любого уровня) до следующего заголовка того же или более высокого уровня. */
15
+ section(heading: string): string | null;
16
+ }
17
+ /** Раздел markdown по тексту заголовка; заголовки внутри ``` не считаются. */
18
+ export declare function extractSection(text: string, heading: string): string | null;
19
+ export declare function doc(rel: string): Promise<Doc>;
20
+ /** Записи индекса источника (собираются из файлов, не из prompts/index.yml). */
21
+ export declare function indexEntries(): Promise<IndexEntry[]>;
22
+ /** Первый ```-блок языка `lang` (в том числе с отступом — в списке), текст которого начинается с `prefix`; отступ снимается. */
23
+ export declare function codeBlock(text: string, lang: string, prefix?: string): string | null;
24
+ export interface ContractDoc extends Doc {
25
+ key: string;
26
+ /** Шаблон AGENTS.md из раздела «Шаблон `AGENTS.md`». */
27
+ agentsMdTemplate: string | null;
28
+ }
29
+ export declare function contract(key: string): Promise<ContractDoc>;
30
+ export interface ModuleDoc extends Doc {
31
+ key: string;
32
+ /** Блок модуля для AGENTS.md между маркерами, с маркерами. */
33
+ agentsMdBlock: string | null;
34
+ /** Первый yaml-блок `provides:` модуля. */
35
+ provides: Record<string, unknown> | null;
36
+ }
37
+ export declare function module(key: string): Promise<ModuleDoc>;
38
+ export declare function modules(): Promise<ModuleDoc[]>;
39
+ export interface SkillDoc extends Doc {
40
+ name: string;
41
+ description: string;
42
+ }
43
+ export declare function skill(name: string): Promise<SkillDoc>;
44
+ export declare function skills(): Promise<SkillDoc[]>;
45
+ export interface PresetDoc {
46
+ key: string;
47
+ title: string;
48
+ contract: string;
49
+ modules: {
50
+ key: string;
51
+ answers: Record<string, unknown>;
52
+ }[];
53
+ /** AGENTS.md, собранный как при установке: шаблон ядра + блоки модулей. */
54
+ agentsMd(): Promise<{
55
+ text: string;
56
+ lines: number;
57
+ bytes: number;
58
+ }>;
59
+ }
60
+ export declare function presets(): Promise<PresetDoc[]>;
@@ -0,0 +1,139 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { normalizeEol, parseFrontmatter } from '../frontmatter.js';
5
+ import { collectEntries } from '../prompt-index.js';
6
+ /** Корень репозитория для зондов: HARNESS_EXPECT_ROOT или cwd. */
7
+ export function repoRoot() {
8
+ return path.resolve(process.env['HARNESS_EXPECT_ROOT'] ?? process.cwd());
9
+ }
10
+ export async function readRepo(rel) {
11
+ return normalizeEol(await readFile(path.join(repoRoot(), rel), 'utf8'));
12
+ }
13
+ const HEADING = /^(#{1,6}) (.+)$/;
14
+ /** Раздел markdown по тексту заголовка; заголовки внутри ``` не считаются. */
15
+ export function extractSection(text, heading) {
16
+ const lines = text.split('\n');
17
+ let inFence = false;
18
+ let start = -1;
19
+ let level = 0;
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const line = lines[i] ?? '';
22
+ if (line.startsWith('```'))
23
+ inFence = !inFence;
24
+ if (inFence)
25
+ continue;
26
+ const m = HEADING.exec(line);
27
+ if (m === null)
28
+ continue;
29
+ const depth = (m[1] ?? '').length;
30
+ if (start === -1) {
31
+ if ((m[2] ?? '').trim() === heading) {
32
+ start = i;
33
+ level = depth;
34
+ }
35
+ }
36
+ else if (depth <= level) {
37
+ return lines.slice(start, i).join('\n').trimEnd();
38
+ }
39
+ }
40
+ return start === -1 ? null : lines.slice(start).join('\n').trimEnd();
41
+ }
42
+ function headingsOf(text) {
43
+ const out = [];
44
+ let inFence = false;
45
+ for (const line of text.split('\n')) {
46
+ if (line.startsWith('```'))
47
+ inFence = !inFence;
48
+ const m = inFence ? null : HEADING.exec(line);
49
+ if (m !== null)
50
+ out.push((m[2] ?? '').trim());
51
+ }
52
+ return out;
53
+ }
54
+ export async function doc(rel) {
55
+ const text = await readRepo(rel);
56
+ return {
57
+ path: rel,
58
+ text,
59
+ frontmatter: text.startsWith('---\n') ? parseFrontmatter(text) : null,
60
+ lines: text.replace(/\n$/, '').split('\n').length,
61
+ bytes: Buffer.byteLength(text, 'utf8'),
62
+ headings: headingsOf(text),
63
+ section: (heading) => extractSection(text, heading),
64
+ };
65
+ }
66
+ let entriesCache = null;
67
+ /** Записи индекса источника (собираются из файлов, не из prompts/index.yml). */
68
+ export function indexEntries() {
69
+ entriesCache ??= collectEntries(repoRoot()).then((r) => r.entries);
70
+ return entriesCache;
71
+ }
72
+ async function entry(kind, key) {
73
+ const found = (await indexEntries()).find((e) => e.kind === kind && e.key === key);
74
+ if (found === undefined)
75
+ throw new Error(`${kind} «${key}» нет в индексе`);
76
+ return found;
77
+ }
78
+ /** Первый ```-блок языка `lang` (в том числе с отступом — в списке), текст которого начинается с `prefix`; отступ снимается. */
79
+ export function codeBlock(text, lang, prefix = '') {
80
+ const re = new RegExp('^([ \\t]*)(`{3,4})' + lang + '\\n([\\s\\S]*?)\\n\\1\\2$', 'gm');
81
+ for (const m of text.matchAll(re)) {
82
+ const indent = (m[1] ?? '').length;
83
+ const body = (m[3] ?? '')
84
+ .split('\n')
85
+ .map((l) => l.slice(Math.min(indent, l.length - l.trimStart().length)))
86
+ .join('\n');
87
+ if (body.startsWith(prefix))
88
+ return body;
89
+ }
90
+ return null;
91
+ }
92
+ export async function contract(key) {
93
+ const e = await entry('contract', key);
94
+ const d = await doc(e.path);
95
+ return { ...d, key, agentsMdTemplate: codeBlock(d.text, 'markdown', '# ') };
96
+ }
97
+ export async function module(key) {
98
+ const e = await entry('module', key);
99
+ const d = await doc(e.path);
100
+ const block = new RegExp(`<!-- module:${key} -->[\\s\\S]*?<!-- /module:${key} -->`).exec(d.text);
101
+ const provides = codeBlock(d.text, 'yaml', 'provides:');
102
+ return {
103
+ ...d,
104
+ key,
105
+ agentsMdBlock: block === null ? null : block[0],
106
+ provides: provides === null ? null : (parse(provides).provides ?? null),
107
+ };
108
+ }
109
+ export async function modules() {
110
+ const keys = (await indexEntries()).filter((e) => e.kind === 'module').map((e) => e.key);
111
+ return Promise.all(keys.map((k) => module(k)));
112
+ }
113
+ export async function skill(name) {
114
+ await entry('skill', name);
115
+ const d = await doc(`skills/${name}/SKILL.md`);
116
+ return { ...d, name, description: String(d.frontmatter?.['description'] ?? '') };
117
+ }
118
+ export async function skills() {
119
+ const names = (await indexEntries()).filter((e) => e.kind === 'skill').map((e) => e.key);
120
+ return Promise.all(names.map((n) => skill(n)));
121
+ }
122
+ export async function presets() {
123
+ const list = (await indexEntries()).filter((e) => e.kind === 'preset');
124
+ return Promise.all(list.map(async (e) => {
125
+ const data = parse(await readRepo(e.path));
126
+ return {
127
+ key: data.key,
128
+ title: data.title,
129
+ contract: data.contract,
130
+ modules: data.modules.map((m) => ({ key: m.key, answers: m.answers ?? {} })),
131
+ agentsMd: async () => {
132
+ const template = (await contract(data.contract)).agentsMdTemplate ?? '';
133
+ const blocks = await Promise.all(data.modules.map(async (m) => (await module(m.key)).agentsMdBlock ?? ''));
134
+ const text = `${[template.trimEnd(), ...blocks].join('\n\n')}\n`;
135
+ return { text, lines: text.replace(/\n$/, '').split('\n').length, bytes: Buffer.byteLength(text, 'utf8') };
136
+ },
137
+ };
138
+ }));
139
+ }
@@ -0,0 +1,13 @@
1
+ /** Экземпляр для проверки: имя (для сообщения) и данные. */
2
+ export interface Instance {
3
+ name: string;
4
+ data: unknown;
5
+ }
6
+ /** Ошибки валидации по JSON Schema; пустой массив — всё соответствует. */
7
+ export declare function schemaErrors(instances: Instance[], schemaPath: string): Promise<string[]>;
8
+ /** Утверждение для ожиданий: все экземпляры соответствуют схеме. */
9
+ export declare function expectSchema(instances: Instance[], schemaPath: string): Promise<void>;
10
+ /** YAML- или JSON-файлы репозитория как экземпляры. */
11
+ export declare function dataFiles(paths: string[]): Promise<Instance[]>;
12
+ /** Frontmatter markdown-файлов как экземпляры. */
13
+ export declare function frontmatters(paths: string[]): Promise<Instance[]>;
@@ -0,0 +1,47 @@
1
+ import { Ajv2020 } from 'ajv/dist/2020.js';
2
+ import { readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { parse } from 'yaml';
5
+ import { parseFrontmatter } from '../frontmatter.js';
6
+ import { readRepo, repoRoot } from './repo.js';
7
+ let ajv = null;
8
+ /**
9
+ * Все `*.schema.json` из каталога схемы — в одном Ajv: схемы ссылаются
10
+ * друг на друга по имени файла (`$ref: provides.schema.json`).
11
+ */
12
+ async function validator(schemaPath) {
13
+ ajv ??= (async () => {
14
+ const dir = path.posix.dirname(schemaPath);
15
+ const instance = new Ajv2020({ allErrors: true, strict: false });
16
+ for (const name of (await readdir(path.join(repoRoot(), dir))).filter((n) => n.endsWith('.schema.json')).sort()) {
17
+ instance.addSchema(JSON.parse(await readRepo(`${dir}/${name}`)), name);
18
+ }
19
+ return instance;
20
+ })();
21
+ const v = (await ajv).getSchema(path.posix.basename(schemaPath));
22
+ if (v === undefined)
23
+ throw new Error(`схема ${schemaPath} не найдена`);
24
+ return v;
25
+ }
26
+ const describeError = (e) => `${e.instancePath === '' ? '(корень)' : e.instancePath} ${e.message ?? ''}${e.keyword === 'additionalProperties' ? `: ${String(e.params.additionalProperty)}` : ''}`.trim();
27
+ /** Ошибки валидации по JSON Schema; пустой массив — всё соответствует. */
28
+ export async function schemaErrors(instances, schemaPath) {
29
+ const validate = await validator(schemaPath);
30
+ return instances.flatMap((i) => (validate(i.data) ? [] : (validate.errors ?? []).map((e) => `${i.name}: ${describeError(e)}`)));
31
+ }
32
+ /** Утверждение для ожиданий: все экземпляры соответствуют схеме. */
33
+ export async function expectSchema(instances, schemaPath) {
34
+ if (instances.length === 0)
35
+ throw new Error(`нет экземпляров для схемы ${schemaPath}`);
36
+ const errors = await schemaErrors(instances, schemaPath);
37
+ if (errors.length > 0)
38
+ throw new Error(`не по схеме ${schemaPath}:\n${errors.join('\n')}`);
39
+ }
40
+ /** YAML- или JSON-файлы репозитория как экземпляры. */
41
+ export async function dataFiles(paths) {
42
+ return Promise.all(paths.map(async (p) => ({ name: p, data: parse(await readRepo(p)) })));
43
+ }
44
+ /** Frontmatter markdown-файлов как экземпляры. */
45
+ export async function frontmatters(paths) {
46
+ return Promise.all(paths.map(async (p) => ({ name: p, data: parseFrontmatter(await readRepo(p)) ?? {} })));
47
+ }
@@ -0,0 +1,35 @@
1
+ export declare const VERDICTS_PATH = "expectations/.verdicts.json";
2
+ /** Вердикт судьи по одному ожиданию. */
3
+ export interface Verdict {
4
+ question: string;
5
+ /** Ссылка на доказательство → SHA-256 его содержимого на момент вердикта. */
6
+ evidence: Record<string, string>;
7
+ verdict: boolean;
8
+ /** Дословная цитата из доказательства, на которой держится ответ. */
9
+ quote: string;
10
+ ref: string;
11
+ reason: string;
12
+ at: string;
13
+ }
14
+ /** Результат смысловой проверки заголовков одного файла ожиданий. */
15
+ export interface LintVerdict {
16
+ /** SHA-256 списка заголовков файла. */
17
+ titles: string;
18
+ contradictions: {
19
+ a: string;
20
+ b: string;
21
+ reason: string;
22
+ }[];
23
+ unmeasurable: {
24
+ id: string;
25
+ reason: string;
26
+ }[];
27
+ at: string;
28
+ }
29
+ export interface VerdictStore {
30
+ verdicts: Record<string, Verdict>;
31
+ lint: Record<string, LintVerdict>;
32
+ }
33
+ export declare function loadVerdicts(): Promise<VerdictStore>;
34
+ /** Ключи отсортированы: diff кэша в PR читается и не конфликтует без нужды. */
35
+ export declare function saveVerdicts(store: VerdictStore): Promise<void>;
@@ -0,0 +1,23 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { repoRoot } from './repo.js';
4
+ export const VERDICTS_PATH = 'expectations/.verdicts.json';
5
+ export async function loadVerdicts() {
6
+ try {
7
+ const data = JSON.parse(await readFile(path.join(repoRoot(), VERDICTS_PATH), 'utf8'));
8
+ return { verdicts: data.verdicts ?? {}, lint: data.lint ?? {} };
9
+ }
10
+ catch (error) {
11
+ if (error.code === 'ENOENT')
12
+ return { verdicts: {}, lint: {} };
13
+ throw error;
14
+ }
15
+ }
16
+ const sortKeys = (obj) => Object.fromEntries(Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
17
+ /** Ключи отсортированы: diff кэша в PR читается и не конфликтует без нужды. */
18
+ export async function saveVerdicts(store) {
19
+ const file = path.join(repoRoot(), VERDICTS_PATH);
20
+ await mkdir(path.dirname(file), { recursive: true });
21
+ const data = { verdicts: sortKeys(store.verdicts), lint: sortKeys(store.lint) };
22
+ await writeFile(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
23
+ }
@@ -8,6 +8,8 @@ export interface IndexEntry {
8
8
  applicableTo: string[];
9
9
  patterns: string[];
10
10
  title?: string;
11
+ /** Чем заменён устаревший промпт: ключ или путь замены. */
12
+ deprecated?: string;
11
13
  /** Блок `feature` модуля-возможности — то, что нужно для чеклиста. */
12
14
  feature?: FeatureInfo;
13
15
  /** Файлы skill: `path` от корня репозитория и SHA-256 каждого. */