@shahimat/harness 0.2.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.
package/README.md CHANGED
@@ -25,6 +25,16 @@ npx -y @shahimat/harness@<version> <команда> --root .
25
25
  - `harness adapters sync` — файлы под Claude Code и Codex из
26
26
  `AGENTS.md` и `.agents/`: `CLAUDE.md`, копии skills, субагенты,
27
27
  выключенная memory агента.
28
+ - `harness expect init` — каркас ожиданий в проекте: конфиг vitest,
29
+ пример файла с бюджетом, npm-скрипты, `.gitignore`.
30
+ - `harness expect report | lint | judge [--apply]` — ожидания в форме
31
+ vitest (`expectations/*.expect.ts`): дерево со статусами, проверка
32
+ заголовков, пакетная смысловая проверка судьёй-агентом с кэшем
33
+ вердиктов. Помощники для проверок — `@shahimat/harness/expect`
34
+ (нужен vitest в проекте): `crit` / `warn` / `note` / `budget`,
35
+ `expectAtMost` (info при > 90% порога), зонды по репозиторию,
36
+ `judged`, проверка JSON Schema (`expectSchema`, `dataFiles`,
37
+ `frontmatters`).
28
38
  - `harness index build | check --raw-base <url>` — индекс
29
39
  `prompts/index.yml` репозитория промптов.
30
40
 
@@ -34,7 +44,7 @@ npx -y @shahimat/harness@<version> <команда> --root .
34
44
  ## `context/harness.yml`
35
45
 
36
46
  ```yaml
37
- version: 0.2.0
47
+ version: 0.3.0
38
48
  adapters: [claude-code, codex]
39
49
  checks:
40
50
  changelog:
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { runAdapters } from './commands/adapters.js';
4
4
  import { runChangelog } from './commands/changelog.js';
5
5
  import { runCheck } from './commands/check.js';
6
6
  import { runContext } from './commands/context.js';
7
+ import { runExpect } from './commands/expect.js';
7
8
  import { runIndex } from './commands/prompt-index.js';
8
9
  import { UsageError } from './diagnostics.js';
9
10
  const USAGE = `Использование:
@@ -11,6 +12,7 @@ const USAGE = `Использование:
11
12
  harness adapters sync [--root <dir>]
12
13
  harness changelog lint [--root <dir>]
13
14
  harness context index [--root <dir>]
15
+ harness expect init | report | lint | judge [--apply] [--root <dir>]
14
16
  harness index build --raw-base <url> [--root <dir>] [--out <file>]
15
17
  harness index check --raw-base <url> [--root <dir>]
16
18
 
@@ -23,6 +25,7 @@ export async function main(argv, now = new Date()) {
23
25
  root: { type: 'string', default: '.' },
24
26
  out: { type: 'string' },
25
27
  'raw-base': { type: 'string' },
28
+ apply: { type: 'boolean' },
26
29
  help: { type: 'boolean', short: 'h' },
27
30
  },
28
31
  });
@@ -35,6 +38,8 @@ export async function main(argv, now = new Date()) {
35
38
  args.out = values.out;
36
39
  if (values['raw-base'] !== undefined)
37
40
  args.rawBase = values['raw-base'];
41
+ if (values.apply === true)
42
+ args.apply = true;
38
43
  const [group, command] = positionals;
39
44
  switch (group) {
40
45
  case 'adapters':
@@ -45,6 +50,8 @@ export async function main(argv, now = new Date()) {
45
50
  return runChangelog(command, args);
46
51
  case 'context':
47
52
  return runContext(command, args);
53
+ case 'expect':
54
+ return runExpect(command, args);
48
55
  case 'index':
49
56
  return runIndex(command, args);
50
57
  default:
@@ -0,0 +1,3 @@
1
+ import type { CommandArgs } from './types.ts';
2
+ export declare const EXPECT_CONFIG = "expectations/vitest.config.ts";
3
+ export declare function runExpect(command: string | undefined, args: CommandArgs): Promise<number>;
@@ -0,0 +1,303 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
3
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { UsageError } from '../diagnostics.js';
8
+ import { applyAnswers, applyLintAnswer, planBatches, renderBatch, renderLintBatch } from '../expect/batch.js';
9
+ import { readEvidence, shaOf } from '../expect/evidence.js';
10
+ import { COLLECT_ENV, idOf } from '../expect/judge.js';
11
+ import { BUDGET_TITLE } from '../expect/levels.js';
12
+ import { lintTitles } from '../expect/lint.js';
13
+ import { loadVerdicts, saveVerdicts, VERDICTS_PATH } from '../expect/verdicts.js';
14
+ export const EXPECT_CONFIG = 'expectations/vitest.config.ts';
15
+ const JUDGE_DIR = 'expectations/.judge';
16
+ /** Запускает vitest проекта на файлах ожиданий и возвращает результаты. */
17
+ function runVitest(root, env = {}) {
18
+ if (!existsSync(path.join(root, EXPECT_CONFIG)))
19
+ throw new UsageError(`нет ${EXPECT_CONFIG}`);
20
+ const require = createRequire(path.join(root, 'package.json'));
21
+ let vitestPkg;
22
+ try {
23
+ vitestPkg = require.resolve('vitest/package.json');
24
+ }
25
+ catch {
26
+ throw new UsageError('vitest не установлен в проекте: npm i -D vitest');
27
+ }
28
+ const bin = JSON.parse(readFileSync(vitestPkg, 'utf8')).bin['vitest'] ?? '';
29
+ const out = path.join(tmpdir(), `harness-expect-${process.pid}-${Date.now()}.json`);
30
+ spawnSync(process.execPath, [path.join(path.dirname(vitestPkg), bin), 'run', '--root', root, '--config', EXPECT_CONFIG, '--reporter=json', `--outputFile=${out}`], { cwd: root, env: { ...process.env, ...env, HARNESS_EXPECT_ROOT: root }, stdio: ['ignore', 'ignore', 'inherit'] });
31
+ if (!existsSync(out))
32
+ throw new Error('vitest не записал отчёт');
33
+ const data = JSON.parse(readFileSync(out, 'utf8'));
34
+ rmSync(out, { force: true });
35
+ return data.testResults.flatMap((file) => file.assertionResults.map((a) => {
36
+ const result = {
37
+ file: path.relative(root, file.name).split(path.sep).join('/'),
38
+ path: a.ancestorTitles,
39
+ title: a.title,
40
+ status: a.status,
41
+ message: (a.failureMessages[0] ?? '').split('\n')[0] ?? '',
42
+ // Обычный it() без обёртки — crit: формальная проверка по умолчанию.
43
+ level: a.meta?.level ?? 'crit',
44
+ notes: a.meta?.notes ?? [],
45
+ };
46
+ if (a.meta?.budget !== undefined)
47
+ result.budget = a.meta.budget;
48
+ return result;
49
+ }));
50
+ }
51
+ const fullName = (t) => [...t.path, t.title].join(' > ');
52
+ function byFile(results) {
53
+ const files = new Map();
54
+ for (const r of results)
55
+ files.set(r.file, [...(files.get(r.file) ?? []), r]);
56
+ return files;
57
+ }
58
+ const titlesSha = (tests) => shaOf(tests.map(fullName).sort().join('\n'));
59
+ const isPending = (t) => t.status === 'failed' && t.message.includes('нет свежего вердикта');
60
+ async function report(args) {
61
+ const all = runVitest(args.root);
62
+ const store = await loadVerdicts();
63
+ const titleIssues = lintTitles(all.filter((r) => r.title !== BUDGET_TITLE).map((r) => ({ file: r.file, path: r.path, title: r.title })));
64
+ let blocked = false;
65
+ let total = { tests: 0, crit: 0, warn: 0, info: 0, pending: 0 };
66
+ for (const [file, tests] of byFile(all)) {
67
+ const budget = tests.find((t) => t.title === BUDGET_TITLE)?.budget ?? { crit: 0, warn: 0 };
68
+ const items = tests.filter((t) => t.title !== BUDGET_TITLE);
69
+ const count = { crit: 0, warn: 0, info: 0, pending: 0 };
70
+ console.log(file);
71
+ let prev = [];
72
+ for (const t of items) {
73
+ t.path.forEach((p, i) => {
74
+ if (prev[i] !== p)
75
+ console.log(`${' '.repeat(i + 1)}${p}`);
76
+ });
77
+ prev = t.path;
78
+ const pad = ' '.repeat(t.path.length + 1);
79
+ if (isPending(t)) {
80
+ count.pending++;
81
+ console.log(`${pad}? ${t.title} — нужен вердикт судьи`);
82
+ }
83
+ else if (t.status === 'failed') {
84
+ count[t.level === 'warn' ? 'warn' : 'crit']++;
85
+ console.log(`${pad}${t.level === 'warn' ? '⚠' : '✗'} ${t.title} — ${t.message}`);
86
+ }
87
+ else {
88
+ console.log(`${pad}${t.status === 'todo' ? '○' : t.status === 'passed' ? '✓' : '-'} ${t.title}`);
89
+ }
90
+ for (const n of t.notes)
91
+ console.log(`${pad} ℹ ${n}`);
92
+ count.info += t.notes.length;
93
+ }
94
+ for (const d of titleIssues.filter((i) => i.title.file === file)) {
95
+ count[d.level]++;
96
+ console.log(` ${d.level === 'crit' ? '✗' : '⚠'} заголовок «${d.title.title}» — ${d.message}`);
97
+ }
98
+ const lint = store.lint[file];
99
+ if (lint === undefined || lint.titles !== titlesSha(items)) {
100
+ count.pending++;
101
+ console.log(' ? смысловая проверка заголовков устарела — harness expect judge');
102
+ }
103
+ else {
104
+ const name = new Map(items.map((t) => [idOf(fullName(t)), t.title]));
105
+ for (const c of lint.contradictions)
106
+ console.log(` ⚠ противоречие: «${name.get(c.a)}» ↔ «${name.get(c.b)}» — ${c.reason}`);
107
+ for (const u of lint.unmeasurable)
108
+ console.log(` ⚠ неизмеримо: «${name.get(u.id)}» — ${u.reason}`);
109
+ count.warn += lint.contradictions.length + lint.unmeasurable.length;
110
+ }
111
+ const over = count.crit > budget.crit || count.warn > budget.warn || count.pending > 0;
112
+ blocked ||= over;
113
+ console.log(` ${over ? '✗' : '✓'} crit ${count.crit}/${budget.crit} · warn ${count.warn}/${budget.warn} · info ${count.info}` +
114
+ (count.pending > 0 ? ` · ? ${count.pending} без вердикта` : ''));
115
+ total = {
116
+ tests: total.tests + items.length,
117
+ crit: total.crit + count.crit,
118
+ warn: total.warn + count.warn,
119
+ info: total.info + count.info,
120
+ pending: total.pending + count.pending,
121
+ };
122
+ }
123
+ console.log(`\nожиданий ${total.tests}: crit ${total.crit} · warn ${total.warn} · info ${total.info} · ? ${total.pending} — ${blocked ? 'бюджет превышен' : 'в бюджете'}`);
124
+ return blocked ? 1 : 0;
125
+ }
126
+ async function lint(args) {
127
+ const titles = runVitest(args.root)
128
+ .filter((r) => r.title !== BUDGET_TITLE)
129
+ .map((r) => ({ file: r.file, path: r.path, title: r.title }));
130
+ const diags = lintTitles(titles);
131
+ for (const d of diags)
132
+ console.error(`${d.level} ${d.title.file}: «${d.title.title}» — ${d.message}`);
133
+ console.log(diags.length === 0 ? `expect lint: ok (${titles.length})` : `expect lint: нарушений ${diags.length}`);
134
+ return diags.length === 0 ? 0 : 1;
135
+ }
136
+ async function judgePrepare(args) {
137
+ const dir = path.join(args.root, JUDGE_DIR);
138
+ await rm(dir, { recursive: true, force: true });
139
+ const pendingDir = path.join(dir, 'pending');
140
+ const results = runVitest(args.root, { [COLLECT_ENV]: pendingDir });
141
+ const items = [];
142
+ if (existsSync(pendingDir)) {
143
+ for (const f of (await readdir(pendingDir)).sort())
144
+ items.push(JSON.parse(await readFile(path.join(pendingDir, f), 'utf8')));
145
+ await rm(pendingDir, { recursive: true, force: true });
146
+ }
147
+ const plan = { batches: [], lint: [] };
148
+ for (const [i, batch] of planBatches(items).entries()) {
149
+ const task = `${JUDGE_DIR}/batch-${i + 1}.md`;
150
+ const answer = `${JUDGE_DIR}/batch-${i + 1}.answer.json`;
151
+ await mkdir(dir, { recursive: true });
152
+ await writeFile(path.join(args.root, task), renderBatch(batch, answer), 'utf8');
153
+ plan.batches.push({ task, answer, items: batch.items });
154
+ }
155
+ const store = await loadVerdicts();
156
+ for (const [file, all] of byFile(results)) {
157
+ const tests = all.filter((t) => t.title !== BUDGET_TITLE);
158
+ const sha = titlesSha(tests);
159
+ if (store.lint[file]?.titles === sha)
160
+ continue;
161
+ const n = plan.lint.length + 1;
162
+ const task = `${JUDGE_DIR}/lint-${n}.md`;
163
+ const answer = `${JUDGE_DIR}/lint-${n}.answer.json`;
164
+ const titles = tests.map((t) => ({ id: idOf(fullName(t)), title: fullName(t) }));
165
+ await mkdir(dir, { recursive: true });
166
+ await writeFile(path.join(args.root, task), renderLintBatch(file, titles, answer), 'utf8');
167
+ plan.lint.push({ task, answer, file, titles: sha, ids: titles.map((t) => t.id) });
168
+ }
169
+ if (plan.batches.length === 0 && plan.lint.length === 0) {
170
+ console.log('expect judge: всё свежее, судья не нужен');
171
+ return 0;
172
+ }
173
+ await writeFile(path.join(dir, 'plan.json'), JSON.stringify(plan, null, 2), 'utf8');
174
+ console.log(`expect judge: вопросов ${items.length} в пакетах ${plan.batches.length}, проверок заголовков ${plan.lint.length}`);
175
+ for (const t of [...plan.batches, ...plan.lint])
176
+ console.log(` ${t.task} → ${t.answer}`);
177
+ console.log('Каждый файл — задание судье (субагенту), ответ — в указанный JSON. Затем: harness expect judge --apply');
178
+ return 1;
179
+ }
180
+ async function judgeApply(args) {
181
+ const planFile = path.join(args.root, JUDGE_DIR, 'plan.json');
182
+ if (!existsSync(planFile))
183
+ throw new UsageError('нет плана: сначала harness expect judge');
184
+ const plan = JSON.parse(await readFile(planFile, 'utf8'));
185
+ const store = await loadVerdicts();
186
+ const rejected = [];
187
+ const readJson = async (rel) => {
188
+ try {
189
+ return JSON.parse(await readFile(path.join(args.root, rel), 'utf8'));
190
+ }
191
+ catch {
192
+ return undefined;
193
+ }
194
+ };
195
+ for (const b of plan.batches) {
196
+ const answers = await readJson(b.answer);
197
+ if (answers === undefined) {
198
+ rejected.push(`${b.answer}: нет ответа`);
199
+ continue;
200
+ }
201
+ const texts = new Map();
202
+ for (const ref of new Set(b.items.flatMap((i) => i.evidence.map((e) => e.ref))))
203
+ texts.set(ref, await readEvidence(ref));
204
+ const result = applyAnswers(b.items, answers, texts, args.now);
205
+ Object.assign(store.verdicts, result.accepted);
206
+ for (const r of result.rejected)
207
+ rejected.push(`${b.answer}: ${r.id} — ${r.reason}`);
208
+ }
209
+ for (const l of plan.lint) {
210
+ const answer = await readJson(l.answer);
211
+ const { verdict, error } = applyLintAnswer(l.titles, new Set(l.ids), answer, args.now);
212
+ if (verdict === null)
213
+ rejected.push(`${l.answer}: ${error ?? 'нет ответа'}`);
214
+ else
215
+ store.lint[l.file] = verdict;
216
+ }
217
+ await saveVerdicts(store);
218
+ for (const r of rejected)
219
+ console.error(r);
220
+ if (rejected.length > 0) {
221
+ console.error(`expect judge --apply: отклонено ${rejected.length}; принятое сохранено в ${VERDICTS_PATH}`);
222
+ return 1;
223
+ }
224
+ await rm(path.join(args.root, JUDGE_DIR), { recursive: true, force: true });
225
+ console.log(`expect judge --apply: вердикты сохранены в ${VERDICTS_PATH}`);
226
+ return 0;
227
+ }
228
+ const INIT_CONFIG = `import { defineConfig } from 'vitest/config';
229
+
230
+ // Ожидания: заголовки crit/warn — для человека, проверки — класс Check ниже в файле.
231
+ // Отчёт: npm run expect. Вердикты судьи: npm run expect:judge → expect:apply.
232
+ export default defineConfig({
233
+ test: {
234
+ include: ['expectations/**/*.expect.ts'],
235
+ testTimeout: 30_000,
236
+ },
237
+ });
238
+ `;
239
+ const initExample = (name) => `import { describe, it } from 'vitest';
240
+ import { budget } from '@shahimat/harness/expect';
241
+
242
+ budget({ crit: 0, warn: 0 });
243
+
244
+ describe('${name}', () => {
245
+ it.todo('первое ожидание: одно утверждение до 200 символов, да/нет или число');
246
+ });
247
+
248
+ // ─────────────────────────────── проверки ───────────────────────────────
249
+
250
+ class Check {}
251
+ void Check;
252
+ `;
253
+ /** Каркас ожиданий в проекте; существующие файлы не трогаются. */
254
+ async function init(args) {
255
+ const done = [];
256
+ const put = async (rel, content) => {
257
+ const abs = path.join(args.root, rel);
258
+ if (existsSync(abs))
259
+ return;
260
+ await mkdir(path.dirname(abs), { recursive: true });
261
+ await writeFile(abs, content, 'utf8');
262
+ done.push(`создан ${rel}`);
263
+ };
264
+ const name = path.basename(args.root);
265
+ await put(EXPECT_CONFIG, INIT_CONFIG);
266
+ await put(`expectations/${name}.expect.ts`, initExample(name));
267
+ const gitignore = path.join(args.root, '.gitignore');
268
+ const ignored = existsSync(gitignore) ? await readFile(gitignore, 'utf8') : '';
269
+ if (!ignored.split('\n').includes(`${JUDGE_DIR}/`)) {
270
+ await writeFile(gitignore, `${ignored}${ignored === '' || ignored.endsWith('\n') ? '' : '\n'}${JUDGE_DIR}/\n`, 'utf8');
271
+ done.push(`.gitignore: ${JUDGE_DIR}/`);
272
+ }
273
+ const pkgFile = path.join(args.root, 'package.json');
274
+ if (existsSync(pkgFile)) {
275
+ const pkg = JSON.parse(await readFile(pkgFile, 'utf8'));
276
+ const scripts = { expect: 'harness expect report --root .', 'expect:judge': 'harness expect judge --root .', 'expect:apply': 'harness expect judge --apply --root .' };
277
+ const added = Object.entries(scripts).filter(([k]) => pkg.scripts?.[k] === undefined);
278
+ if (added.length > 0) {
279
+ pkg.scripts = { ...pkg.scripts, ...Object.fromEntries(added) };
280
+ await writeFile(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
281
+ done.push(`package.json: скрипты ${added.map(([k]) => k).join(', ')}`);
282
+ }
283
+ }
284
+ for (const line of done)
285
+ console.log(line);
286
+ console.log(done.length === 0 ? 'expect init: всё уже на месте' : 'expect init: готово');
287
+ console.log('Зависимости: npm i -D vitest @shahimat/harness [@shahimat/expectations]');
288
+ return 0;
289
+ }
290
+ export async function runExpect(command, args) {
291
+ switch (command) {
292
+ case 'init':
293
+ return init(args);
294
+ case 'report':
295
+ return report(args);
296
+ case 'lint':
297
+ return lint(args);
298
+ case 'judge':
299
+ return args.apply === true ? judgeApply(args) : judgePrepare(args);
300
+ default:
301
+ throw new UsageError(`неизвестная команда: expect ${command ?? ''}`.trimEnd());
302
+ }
303
+ }
@@ -3,6 +3,8 @@ export interface CommandArgs {
3
3
  root: string;
4
4
  out?: string;
5
5
  rawBase?: string;
6
+ /** `expect judge --apply`: применить ответы судьи. */
7
+ apply?: boolean;
6
8
  /** Текущий момент; подменяется в тестах. */
7
9
  now: Date;
8
10
  }
@@ -0,0 +1,44 @@
1
+ import type { PendingItem } from './judge.ts';
2
+ import type { LintVerdict, Verdict } from './verdicts.ts';
3
+ export interface BatchLimits {
4
+ maxItems: number;
5
+ maxBytes: number;
6
+ }
7
+ export declare const DEFAULT_LIMITS: BatchLimits;
8
+ export interface Batch {
9
+ items: PendingItem[];
10
+ /** Уникальные доказательства пакета — судья читает каждое один раз. */
11
+ refs: string[];
12
+ bytes: number;
13
+ }
14
+ /**
15
+ * Пакеты для судьи: ожидания с одинаковыми доказательствами — вместе,
16
+ * группы складываются в пакеты до лимита вопросов и объёма доказательств.
17
+ */
18
+ export declare function planBatches(items: PendingItem[], limits?: BatchLimits): Batch[];
19
+ export declare function renderBatch(batch: Batch, answerFile: string): string;
20
+ export interface Answer {
21
+ id: string;
22
+ verdict: boolean;
23
+ ref: string;
24
+ quote: string;
25
+ reason: string;
26
+ }
27
+ export interface ApplyResult {
28
+ accepted: Record<string, Verdict>;
29
+ rejected: {
30
+ id: string;
31
+ reason: string;
32
+ }[];
33
+ }
34
+ /** Проверяет ответы судьи; цитата должна дословно встречаться в доказательстве. */
35
+ export declare function applyAnswers(items: PendingItem[], answers: unknown, evidenceText: Map<string, string>, now: Date): ApplyResult;
36
+ /** Смысловая проверка заголовков одного файла ожиданий — один проход. */
37
+ export declare function renderLintBatch(file: string, titles: {
38
+ id: string;
39
+ title: string;
40
+ }[], answerFile: string): string;
41
+ export declare function applyLintAnswer(titlesSha: string, ids: Set<string>, answer: unknown, now: Date): {
42
+ verdict: LintVerdict | null;
43
+ error: string | null;
44
+ };
@@ -0,0 +1,157 @@
1
+ import { containsQuote } from './evidence.js';
2
+ export const DEFAULT_LIMITS = { maxItems: 25, maxBytes: 80_000 };
3
+ /**
4
+ * Пакеты для судьи: ожидания с одинаковыми доказательствами — вместе,
5
+ * группы складываются в пакеты до лимита вопросов и объёма доказательств.
6
+ */
7
+ export function planBatches(items, limits = DEFAULT_LIMITS) {
8
+ const groups = new Map();
9
+ for (const item of [...items].sort((a, b) => (a.id < b.id ? -1 : 1))) {
10
+ const key = item.evidence.map((e) => e.ref).sort().join('\n');
11
+ groups.set(key, [...(groups.get(key) ?? []), item]);
12
+ }
13
+ const batches = [];
14
+ const sizes = new Map();
15
+ const bytesOf = (refs) => [...refs].reduce((n, r) => n + (sizes.get(r) ?? 0), 0);
16
+ let current = { items: [], refs: new Set() };
17
+ const flush = () => {
18
+ if (current.items.length === 0)
19
+ return;
20
+ batches.push({ items: current.items, refs: [...current.refs].sort(), bytes: bytesOf(current.refs) });
21
+ current = { items: [], refs: new Set() };
22
+ };
23
+ for (const group of groups.values()) {
24
+ for (const e of group[0]?.evidence ?? [])
25
+ sizes.set(e.ref, e.bytes);
26
+ for (const item of group) {
27
+ const refs = new Set([...current.refs, ...item.evidence.map((e) => e.ref)]);
28
+ if (current.items.length >= limits.maxItems || (current.items.length > 0 && bytesOf(refs) > limits.maxBytes))
29
+ flush();
30
+ current.items.push(item);
31
+ for (const e of item.evidence)
32
+ current.refs.add(e.ref);
33
+ }
34
+ }
35
+ flush();
36
+ return batches;
37
+ }
38
+ export function renderBatch(batch, answerFile) {
39
+ const lines = [
40
+ '# Пакет для судьи ожиданий',
41
+ '',
42
+ 'Ты — судья. Прочитай доказательства (каждое один раз) и ответь на',
43
+ 'каждый вопрос: выполнено ли утверждение — да (true) или нет (false).',
44
+ 'Отвечай только по доказательствам, без догадок.',
45
+ '',
46
+ 'Для каждого ответа — дословная цитата (8–300 символов) из одного',
47
+ 'доказательства и его ссылка (`ref`). Цитата проверяется программно:',
48
+ 'не найдена в тексте — ответ отклоняется. Для «нет» — цитата того, что',
49
+ 'противоречит или ближе всего к утверждению, и причина.',
50
+ '',
51
+ `Ответ — JSON-массив в файл \`${answerFile}\`:`,
52
+ '',
53
+ '```json',
54
+ '[{"id": "<id>", "verdict": true, "ref": "<ref>", "quote": "<цитата>", "reason": "<одна строка>"}]',
55
+ '```',
56
+ '',
57
+ '## Доказательства',
58
+ '',
59
+ 'Ссылка `path#Заголовок` — раздел markdown с этим заголовком.',
60
+ '',
61
+ ...batch.refs.map((r) => `- \`${r}\``),
62
+ '',
63
+ '## Вопросы',
64
+ '',
65
+ ];
66
+ for (const item of batch.items) {
67
+ lines.push(`- \`${item.id}\` — ${item.question.split(' > ').join(' → ')}`);
68
+ lines.push(` доказательства: ${item.evidence.map((e) => `\`${e.ref}\``).join(', ')}`);
69
+ }
70
+ return `${lines.join('\n')}\n`;
71
+ }
72
+ /** Проверяет ответы судьи; цитата должна дословно встречаться в доказательстве. */
73
+ export function applyAnswers(items, answers, evidenceText, now) {
74
+ const result = { accepted: {}, rejected: [] };
75
+ if (!Array.isArray(answers)) {
76
+ result.rejected.push({ id: '*', reason: 'ответ — не JSON-массив' });
77
+ return result;
78
+ }
79
+ const byId = new Map(items.map((i) => [i.id, i]));
80
+ for (const raw of answers) {
81
+ const id = String(raw.id ?? '');
82
+ const item = byId.get(id);
83
+ if (item === undefined) {
84
+ result.rejected.push({ id, reason: 'неизвестный id' });
85
+ continue;
86
+ }
87
+ if (typeof raw.verdict !== 'boolean') {
88
+ result.rejected.push({ id, reason: 'verdict — не true/false' });
89
+ continue;
90
+ }
91
+ const ref = String(raw.ref ?? '');
92
+ if (!item.evidence.some((e) => e.ref === ref)) {
93
+ result.rejected.push({ id, reason: `ref «${ref}» не из доказательств вопроса` });
94
+ continue;
95
+ }
96
+ const quote = String(raw.quote ?? '');
97
+ if (quote.length > 300 || !containsQuote(evidenceText.get(ref) ?? '', quote)) {
98
+ result.rejected.push({ id, reason: 'цитата не найдена в доказательстве дословно' });
99
+ continue;
100
+ }
101
+ result.accepted[id] = {
102
+ question: item.question,
103
+ evidence: Object.fromEntries(item.evidence.map((e) => [e.ref, e.sha])),
104
+ verdict: raw.verdict,
105
+ quote,
106
+ ref,
107
+ reason: String(raw.reason ?? ''),
108
+ at: now.toISOString().slice(0, 10),
109
+ };
110
+ }
111
+ for (const item of items) {
112
+ if (!(item.id in result.accepted) && !result.rejected.some((r) => r.id === item.id)) {
113
+ result.rejected.push({ id: item.id, reason: 'нет ответа' });
114
+ }
115
+ }
116
+ return result;
117
+ }
118
+ /** Смысловая проверка заголовков одного файла ожиданий — один проход. */
119
+ export function renderLintBatch(file, titles, answerFile) {
120
+ return [
121
+ '# Проверка заголовков ожиданий',
122
+ '',
123
+ `Файл: \`${file}\`. Каждая строка — ожидание человека к реализации.`,
124
+ 'Найди:',
125
+ '',
126
+ '1. Противоречия — пары ожиданий, которые не могут быть выполнены',
127
+ ' одновременно.',
128
+ '2. Неизмеримые — ожидания, на которые нельзя ответить «да/нет» или',
129
+ ' числом (размыто, оценочно, без порога).',
130
+ '',
131
+ 'Ничего не нашёл — пустые массивы. Не выдумывай проблем.',
132
+ '',
133
+ `Ответ — JSON в файл \`${answerFile}\`:`,
134
+ '',
135
+ '```json',
136
+ '{"contradictions": [{"a": "<id>", "b": "<id>", "reason": "<строка>"}], "unmeasurable": [{"id": "<id>", "reason": "<строка>"}]}',
137
+ '```',
138
+ '',
139
+ '## Ожидания',
140
+ '',
141
+ ...titles.map((t) => `- \`${t.id}\` — ${t.title}`),
142
+ '',
143
+ ].join('\n');
144
+ }
145
+ export function applyLintAnswer(titlesSha, ids, answer, now) {
146
+ const a = answer;
147
+ if (a === null || typeof a !== 'object' || !Array.isArray(a.contradictions) || !Array.isArray(a.unmeasurable)) {
148
+ return { verdict: null, error: 'ответ — не {contradictions: [], unmeasurable: []}' };
149
+ }
150
+ const bad = [...a.contradictions.flatMap((c) => [c.a, c.b]), ...a.unmeasurable.map((u) => u.id)].filter((id) => !ids.has(id));
151
+ if (bad.length > 0)
152
+ return { verdict: null, error: `неизвестные id: ${bad.join(', ')}` };
153
+ return {
154
+ verdict: { titles: titlesSha, contradictions: a.contradictions, unmeasurable: a.unmeasurable, at: now.toISOString().slice(0, 10) },
155
+ error: null,
156
+ };
157
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shahimat/harness",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Harness для проектов на контрактах ai-prompts: генераторы и проверки",
5
5
  "license": "MIT",
6
6
  "author": "Gevorg Nakhapetyan",
@@ -33,6 +33,10 @@
33
33
  ".": {
34
34
  "types": "./dist/index.d.ts",
35
35
  "import": "./dist/index.js"
36
+ },
37
+ "./expect": {
38
+ "types": "./dist/expect/index.d.ts",
39
+ "import": "./dist/expect/index.js"
36
40
  }
37
41
  },
38
42
  "files": [
@@ -46,6 +50,7 @@
46
50
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
47
51
  },
48
52
  "dependencies": {
53
+ "ajv": "^8.20.0",
49
54
  "yaml": "^2.9.1"
50
55
  },
51
56
  "devDependencies": {
@@ -55,5 +60,13 @@
55
60
  },
56
61
  "publishConfig": {
57
62
  "access": "public"
63
+ },
64
+ "peerDependencies": {
65
+ "vitest": ">=4"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "vitest": {
69
+ "optional": true
70
+ }
58
71
  }
59
72
  }