@shahimat/harness 0.0.0-stage → 0.1.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 +339 -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 +47 -0
- package/dist/prompt-index.js +260 -0
- package/package.json +57 -4
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { formatDiagnostic } from '../diagnostics.js';
|
|
2
|
+
/** Печатает нарушения и возвращает код выхода. */
|
|
3
|
+
export function report(label, diags) {
|
|
4
|
+
if (diags.length === 0) {
|
|
5
|
+
console.log(`${label}: ok`);
|
|
6
|
+
return 0;
|
|
7
|
+
}
|
|
8
|
+
for (const d of diags)
|
|
9
|
+
console.error(formatDiagnostic(d));
|
|
10
|
+
console.error(`${label}: нарушений ${diags.length}`);
|
|
11
|
+
return 1;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type AdapterName } from './adapters/sync.ts';
|
|
2
|
+
export declare const CONFIG_PATH = "context/harness.yml";
|
|
3
|
+
export interface ChangelogOptions {
|
|
4
|
+
maxLine: number;
|
|
5
|
+
tags: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare const DEFAULT_CHANGELOG: ChangelogOptions;
|
|
8
|
+
export interface HarnessConfig {
|
|
9
|
+
version?: string;
|
|
10
|
+
adapters: AdapterName[];
|
|
11
|
+
/** Включённые проверки; ключ отсутствует — проверка выключена. */
|
|
12
|
+
checks: {
|
|
13
|
+
changelog?: ChangelogOptions;
|
|
14
|
+
'context-index'?: Record<string, never>;
|
|
15
|
+
adapters?: Record<string, never>;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export declare function parseConfig(text: string): HarnessConfig;
|
|
19
|
+
/** Читает `context/harness.yml`; нет файла — `null`. */
|
|
20
|
+
export declare function loadConfig(root: string): Promise<HarnessConfig | null>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { KNOWN_ADAPTERS } from './adapters/sync.js';
|
|
5
|
+
import { UsageError } from './diagnostics.js';
|
|
6
|
+
export const CONFIG_PATH = 'context/harness.yml';
|
|
7
|
+
export const DEFAULT_CHANGELOG = {
|
|
8
|
+
maxLine: 120,
|
|
9
|
+
tags: ['arch', 'stack', 'dep', 'milestone'],
|
|
10
|
+
};
|
|
11
|
+
const TOP_LEVEL_KEYS = ['version', 'adapters', 'checks', 'worktrees', 'rules'];
|
|
12
|
+
const KNOWN_CHECKS = ['changelog', 'context-index', 'adapters'];
|
|
13
|
+
function fail(message) {
|
|
14
|
+
throw new UsageError(`${CONFIG_PATH}: ${message}`);
|
|
15
|
+
}
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function unknownKeys(obj, known, where) {
|
|
20
|
+
const extra = Object.keys(obj).filter((k) => !known.includes(k));
|
|
21
|
+
if (extra.length > 0)
|
|
22
|
+
fail(`неизвестные ключи в ${where}: ${extra.join(', ')}`);
|
|
23
|
+
}
|
|
24
|
+
function stringList(value, where) {
|
|
25
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string' && v !== '')) {
|
|
26
|
+
fail(`${where} должно быть списком непустых строк`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function parseChangelog(value) {
|
|
31
|
+
if (value === null || value === undefined)
|
|
32
|
+
return { ...DEFAULT_CHANGELOG };
|
|
33
|
+
if (!isRecord(value))
|
|
34
|
+
fail('checks.changelog должно быть объектом');
|
|
35
|
+
unknownKeys(value, ['max_line', 'tags'], 'checks.changelog');
|
|
36
|
+
const maxLine = value['max_line'] ?? DEFAULT_CHANGELOG.maxLine;
|
|
37
|
+
if (typeof maxLine !== 'number' || !Number.isInteger(maxLine) || maxLine <= 0) {
|
|
38
|
+
fail('checks.changelog.max_line должно быть целым числом > 0');
|
|
39
|
+
}
|
|
40
|
+
const tags = value['tags'] === undefined ? DEFAULT_CHANGELOG.tags : stringList(value['tags'], 'checks.changelog.tags');
|
|
41
|
+
if (tags.length === 0)
|
|
42
|
+
fail('checks.changelog.tags не может быть пустым');
|
|
43
|
+
return { maxLine, tags: [...tags] };
|
|
44
|
+
}
|
|
45
|
+
export function parseConfig(text) {
|
|
46
|
+
let data;
|
|
47
|
+
try {
|
|
48
|
+
data = parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
fail(`невалидный YAML: ${error.message.split('\n')[0]}`);
|
|
52
|
+
}
|
|
53
|
+
if (data === null || data === undefined)
|
|
54
|
+
data = {};
|
|
55
|
+
if (!isRecord(data))
|
|
56
|
+
fail('корень должен быть объектом');
|
|
57
|
+
unknownKeys(data, TOP_LEVEL_KEYS, 'корне');
|
|
58
|
+
const config = { adapters: [], checks: {} };
|
|
59
|
+
const version = data['version'];
|
|
60
|
+
if (version !== undefined) {
|
|
61
|
+
if (typeof version !== 'string' || version === '')
|
|
62
|
+
fail('version должно быть строкой');
|
|
63
|
+
config.version = version;
|
|
64
|
+
}
|
|
65
|
+
if (data['adapters'] !== undefined) {
|
|
66
|
+
const adapters = stringList(data['adapters'], 'adapters');
|
|
67
|
+
const unknown = adapters.filter((a) => !KNOWN_ADAPTERS.includes(a));
|
|
68
|
+
if (unknown.length > 0)
|
|
69
|
+
fail(`неизвестные adapters: ${unknown.join(', ')} (есть: ${KNOWN_ADAPTERS.join(', ')})`);
|
|
70
|
+
config.adapters = adapters;
|
|
71
|
+
}
|
|
72
|
+
const checks = data['checks'] ?? {};
|
|
73
|
+
if (!isRecord(checks))
|
|
74
|
+
fail('checks должно быть объектом');
|
|
75
|
+
unknownKeys(checks, KNOWN_CHECKS, 'checks');
|
|
76
|
+
if ('changelog' in checks)
|
|
77
|
+
config.checks.changelog = parseChangelog(checks['changelog']);
|
|
78
|
+
for (const name of ['context-index', 'adapters']) {
|
|
79
|
+
if (!(name in checks))
|
|
80
|
+
continue;
|
|
81
|
+
const options = checks[name] ?? {};
|
|
82
|
+
if (!isRecord(options))
|
|
83
|
+
fail(`checks.${name} должно быть объектом`);
|
|
84
|
+
unknownKeys(options, [], `checks.${name}`);
|
|
85
|
+
config.checks[name] = {};
|
|
86
|
+
}
|
|
87
|
+
return config;
|
|
88
|
+
}
|
|
89
|
+
/** Читает `context/harness.yml`; нет файла — `null`. */
|
|
90
|
+
export async function loadConfig(root) {
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
text = await readFile(path.join(root, CONFIG_PATH), 'utf8');
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error.code === 'ENOENT')
|
|
97
|
+
return null;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
return parseConfig(text);
|
|
101
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Найденное проверкой нарушение. */
|
|
2
|
+
export interface Diagnostic {
|
|
3
|
+
/** Путь от корня проекта, через `/`. */
|
|
4
|
+
file: string;
|
|
5
|
+
/** Номер строки с 1; нет — нарушение относится к файлу целиком. */
|
|
6
|
+
line?: number;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function formatDiagnostic(d: Diagnostic): string;
|
|
10
|
+
/** Ошибка вызова или конфигурации: код выхода 2, а не 1. */
|
|
11
|
+
export declare class UsageError extends Error {
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type Frontmatter = Record<string, unknown>;
|
|
2
|
+
/** Приводит переводы строк к LF: результат не зависит от core.autocrlf. */
|
|
3
|
+
export declare function normalizeEol(text: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Достаёт YAML-frontmatter: блок между `---` в первой строке и следующей
|
|
6
|
+
* строкой `---`. Нет блока — `null`. Невалидный YAML — исключение.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseFrontmatter(text: string): Frontmatter | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
/** Приводит переводы строк к LF: результат не зависит от core.autocrlf. */
|
|
3
|
+
export function normalizeEol(text) {
|
|
4
|
+
return text.replace(/\r\n?/g, '\n');
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Достаёт YAML-frontmatter: блок между `---` в первой строке и следующей
|
|
8
|
+
* строкой `---`. Нет блока — `null`. Невалидный YAML — исключение.
|
|
9
|
+
*/
|
|
10
|
+
export function parseFrontmatter(text) {
|
|
11
|
+
const lines = normalizeEol(text).split('\n');
|
|
12
|
+
if (lines[0] !== '---')
|
|
13
|
+
return null;
|
|
14
|
+
const end = lines.indexOf('---', 1);
|
|
15
|
+
if (end === -1)
|
|
16
|
+
return null;
|
|
17
|
+
const data = parse(lines.slice(1, end).join('\n'));
|
|
18
|
+
if (data === null || data === undefined)
|
|
19
|
+
return {};
|
|
20
|
+
if (typeof data !== 'object' || Array.isArray(data)) {
|
|
21
|
+
throw new Error('frontmatter должен быть YAML-объектом');
|
|
22
|
+
}
|
|
23
|
+
return data;
|
|
24
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { normalizeEol, parseFrontmatter, type Frontmatter } from './frontmatter.ts';
|
|
2
|
+
export { collectEntries, diffIndex, INDEX_PATH, renderIndex, sha256, stripTimestamp, type CollectResult, type FeatureInfo, type IndexDiff, type IndexEntry, type RenderOptions, } from './prompt-index.ts';
|
|
3
|
+
export { checkChangelog, lintChangelog, CHANGELOG_PATH } from './checks/changelog.ts';
|
|
4
|
+
export { CONFIG_PATH, DEFAULT_CHANGELOG, loadConfig, parseConfig, type ChangelogOptions, type HarnessConfig, } from './config.ts';
|
|
5
|
+
export { formatDiagnostic, UsageError, type Diagnostic } from './diagnostics.ts';
|
|
6
|
+
export { buildContextIndex, checkContextIndex, CONTEXT_INDEX_PATH, type ContextIndexResult } from './checks/context-index.ts';
|
|
7
|
+
export { applyActions, checkAdapters, computeSync, KNOWN_ADAPTERS, type Action, type AdapterName, type SyncResult, } from './adapters/sync.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { normalizeEol, parseFrontmatter } from './frontmatter.js';
|
|
2
|
+
export { collectEntries, diffIndex, INDEX_PATH, renderIndex, sha256, stripTimestamp, } from './prompt-index.js';
|
|
3
|
+
export { checkChangelog, lintChangelog, CHANGELOG_PATH } from './checks/changelog.js';
|
|
4
|
+
export { CONFIG_PATH, DEFAULT_CHANGELOG, loadConfig, parseConfig, } from './config.js';
|
|
5
|
+
export { formatDiagnostic, UsageError } from './diagnostics.js';
|
|
6
|
+
export { buildContextIndex, checkContextIndex, CONTEXT_INDEX_PATH } from './checks/context-index.js';
|
|
7
|
+
export { applyActions, checkAdapters, computeSync, KNOWN_ADAPTERS, } from './adapters/sync.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export declare const INDEX_PATH = "prompts/index.yml";
|
|
2
|
+
export interface IndexEntry {
|
|
3
|
+
kind: string;
|
|
4
|
+
key: string;
|
|
5
|
+
/** Путь от корня репозитория, всегда через `/`. */
|
|
6
|
+
path: string;
|
|
7
|
+
sha256: string;
|
|
8
|
+
applicableTo: string[];
|
|
9
|
+
patterns: string[];
|
|
10
|
+
title?: string;
|
|
11
|
+
/** Блок `feature` модуля-возможности — то, что нужно для чеклиста. */
|
|
12
|
+
feature?: FeatureInfo;
|
|
13
|
+
/** Файлы skill: `path` от корня репозитория и SHA-256 каждого. */
|
|
14
|
+
files?: {
|
|
15
|
+
path: string;
|
|
16
|
+
sha256: string;
|
|
17
|
+
}[];
|
|
18
|
+
}
|
|
19
|
+
export interface FeatureInfo {
|
|
20
|
+
summary: string;
|
|
21
|
+
default: boolean;
|
|
22
|
+
requires: string[];
|
|
23
|
+
conflicts: string[];
|
|
24
|
+
}
|
|
25
|
+
export interface CollectResult {
|
|
26
|
+
entries: IndexEntry[];
|
|
27
|
+
/** Файлы без `key` / `kind` во frontmatter (README и т.п.). */
|
|
28
|
+
skipped: string[];
|
|
29
|
+
}
|
|
30
|
+
/** SHA-256 от содержимого с LF, чтобы хэш совпадал на любой ОС. */
|
|
31
|
+
export declare function sha256(content: string): string;
|
|
32
|
+
export declare function collectEntries(root: string): Promise<CollectResult>;
|
|
33
|
+
export interface RenderOptions {
|
|
34
|
+
/** Префикс raw-URL без завершающего `/`. */
|
|
35
|
+
rawBase: string;
|
|
36
|
+
generatedAt: Date;
|
|
37
|
+
}
|
|
38
|
+
export declare function renderIndex(entries: IndexEntry[], options: RenderOptions): string;
|
|
39
|
+
/** Убирает строку `generated_at`: она меняется на каждой сборке. */
|
|
40
|
+
export declare function stripTimestamp(index: string): string;
|
|
41
|
+
export interface IndexDiff {
|
|
42
|
+
line: number;
|
|
43
|
+
expected: string | undefined;
|
|
44
|
+
actual: string | undefined;
|
|
45
|
+
}
|
|
46
|
+
/** Построчное сравнение без учёта `generated_at`; пустой массив — индексы совпадают. */
|
|
47
|
+
export declare function diffIndex(expected: string, actual: string): IndexDiff[];
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { stringify } from 'yaml';
|
|
5
|
+
import { normalizeEol, parseFrontmatter } from './frontmatter.js';
|
|
6
|
+
export const INDEX_PATH = 'prompts/index.yml';
|
|
7
|
+
const SOURCE_DIRS = ['prompts', 'patterns'];
|
|
8
|
+
const HEADER = '# generated by harness index build — do not edit manually';
|
|
9
|
+
const SKILLS_DIR = 'skills';
|
|
10
|
+
const AGENTS_DIR = 'agents';
|
|
11
|
+
const GROUPS = [
|
|
12
|
+
{ kind: 'contract', label: 'contracts' },
|
|
13
|
+
{ kind: 'pattern', label: 'patterns' },
|
|
14
|
+
{ kind: 'module', label: 'modules' },
|
|
15
|
+
{ kind: 'skill', label: 'skills' },
|
|
16
|
+
{ kind: 'agent', label: 'agents' },
|
|
17
|
+
{ kind: 'prompt', label: 'prompts' },
|
|
18
|
+
];
|
|
19
|
+
/** Поля `feature` во frontmatter; `asks` читается из самого модуля, в индекс не идёт. */
|
|
20
|
+
const FEATURE_KEYS = ['summary', 'default', 'requires', 'conflicts', 'asks'];
|
|
21
|
+
/** SHA-256 от содержимого с LF, чтобы хэш совпадал на любой ОС. */
|
|
22
|
+
export function sha256(content) {
|
|
23
|
+
return createHash('sha256').update(normalizeEol(content), 'utf8').digest('hex');
|
|
24
|
+
}
|
|
25
|
+
function stringList(value, field, file) {
|
|
26
|
+
if (value === undefined || value === null)
|
|
27
|
+
return [];
|
|
28
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) {
|
|
29
|
+
throw new Error(`${file}: поле ${field} должно быть списком строк`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function oneLine(value, field, file) {
|
|
34
|
+
if (value === undefined || value === null)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (typeof value !== 'string' || value.trim() === '' || value.trim().includes('\n')) {
|
|
37
|
+
throw new Error(`${file}: поле ${field} должно быть непустой строкой в одну строку`);
|
|
38
|
+
}
|
|
39
|
+
return value.trim();
|
|
40
|
+
}
|
|
41
|
+
function parseFeature(value, kind, file) {
|
|
42
|
+
if (value === undefined || value === null)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (kind !== 'module')
|
|
45
|
+
throw new Error(`${file}: feature допустим только у kind: module`);
|
|
46
|
+
if (typeof value !== 'object' || Array.isArray(value))
|
|
47
|
+
throw new Error(`${file}: feature должно быть объектом`);
|
|
48
|
+
const feature = value;
|
|
49
|
+
const extra = Object.keys(feature).filter((k) => !FEATURE_KEYS.includes(k));
|
|
50
|
+
if (extra.length > 0)
|
|
51
|
+
throw new Error(`${file}: неизвестные поля feature: ${extra.join(', ')}`);
|
|
52
|
+
const summary = oneLine(feature['summary'], 'feature.summary', file);
|
|
53
|
+
if (summary === undefined)
|
|
54
|
+
throw new Error(`${file}: нет feature.summary`);
|
|
55
|
+
if (typeof feature['default'] !== 'boolean')
|
|
56
|
+
throw new Error(`${file}: feature.default должно быть true или false`);
|
|
57
|
+
return {
|
|
58
|
+
summary,
|
|
59
|
+
default: feature['default'],
|
|
60
|
+
requires: stringList(feature['requires'], 'feature.requires', file),
|
|
61
|
+
conflicts: stringList(feature['conflicts'], 'feature.conflicts', file),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** requires / conflicts ссылаются только на модули из индекса и не на себя. */
|
|
65
|
+
function checkFeatureRefs(entries) {
|
|
66
|
+
const modules = new Set(entries.filter((e) => e.kind === 'module').map((e) => e.key));
|
|
67
|
+
for (const e of entries) {
|
|
68
|
+
if (e.feature === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
for (const field of ['requires', 'conflicts']) {
|
|
71
|
+
for (const ref of e.feature[field]) {
|
|
72
|
+
if (ref === e.key)
|
|
73
|
+
throw new Error(`${e.path}: feature.${field} ссылается на сам модуль`);
|
|
74
|
+
if (!modules.has(ref))
|
|
75
|
+
throw new Error(`${e.path}: feature.${field}: модуля «${ref}» нет в индексе`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function compareBytes(a, b) {
|
|
81
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
async function listMarkdown(root) {
|
|
84
|
+
const files = [];
|
|
85
|
+
for (const dir of SOURCE_DIRS) {
|
|
86
|
+
let names;
|
|
87
|
+
try {
|
|
88
|
+
names = await readdir(path.join(root, dir), { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error.code === 'ENOENT')
|
|
92
|
+
continue;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
if (name.endsWith('.md'))
|
|
97
|
+
files.push(`${dir}/${name.split(path.sep).join('/')}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return files.sort(compareBytes);
|
|
101
|
+
}
|
|
102
|
+
export async function collectEntries(root) {
|
|
103
|
+
const entries = [];
|
|
104
|
+
const skipped = [];
|
|
105
|
+
for (const file of await listMarkdown(root)) {
|
|
106
|
+
const content = await readFile(path.join(root, file), 'utf8');
|
|
107
|
+
let fm;
|
|
108
|
+
try {
|
|
109
|
+
fm = parseFrontmatter(content);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
throw new Error(`${file}: ${error.message}`, { cause: error });
|
|
113
|
+
}
|
|
114
|
+
const key = fm?.['key'];
|
|
115
|
+
const kind = fm?.['kind'];
|
|
116
|
+
if (typeof key !== 'string' || key === '' || typeof kind !== 'string' || kind === '') {
|
|
117
|
+
skipped.push(file);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const entry = {
|
|
121
|
+
kind,
|
|
122
|
+
key,
|
|
123
|
+
path: file,
|
|
124
|
+
sha256: sha256(content),
|
|
125
|
+
applicableTo: stringList(fm?.['applicable_to'], 'applicable_to', file),
|
|
126
|
+
patterns: stringList(fm?.['patterns'], 'patterns', file),
|
|
127
|
+
};
|
|
128
|
+
const title = oneLine(fm?.['title'], 'title', file);
|
|
129
|
+
if (title !== undefined)
|
|
130
|
+
entry.title = title;
|
|
131
|
+
const feature = parseFeature(fm?.['feature'], kind, file);
|
|
132
|
+
if (feature !== undefined)
|
|
133
|
+
entry.feature = feature;
|
|
134
|
+
entries.push(entry);
|
|
135
|
+
}
|
|
136
|
+
entries.push(...(await collectSkills(root)), ...(await collectAgents(root)));
|
|
137
|
+
checkFeatureRefs(entries);
|
|
138
|
+
return { entries, skipped };
|
|
139
|
+
}
|
|
140
|
+
async function listNames(abs) {
|
|
141
|
+
try {
|
|
142
|
+
return (await readdir(abs)).sort(compareBytes);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error.code === 'ENOENT')
|
|
146
|
+
return [];
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** name во frontmatter совпадает с именем; description — однострочный title записи. */
|
|
151
|
+
function nameAndTitle(content, expected, file) {
|
|
152
|
+
let fm;
|
|
153
|
+
try {
|
|
154
|
+
fm = parseFrontmatter(content);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
throw new Error(`${file}: ${error.message}`, { cause: error });
|
|
158
|
+
}
|
|
159
|
+
if (fm?.['name'] !== expected)
|
|
160
|
+
throw new Error(`${file}: name должно совпадать с именем «${expected}»`);
|
|
161
|
+
const title = oneLine(fm['description'], 'description', file);
|
|
162
|
+
if (title === undefined)
|
|
163
|
+
throw new Error(`${file}: нет description`);
|
|
164
|
+
return title;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Skill — каталог `skills/<name>/` с `SKILL.md` по стандарту Agent Skills.
|
|
168
|
+
* SHA-256 skill — от строк `<sha256 файла> <путь внутри skill>`, по одной
|
|
169
|
+
* на файл, в порядке путей: меняется при правке любого файла.
|
|
170
|
+
*/
|
|
171
|
+
async function collectSkills(root) {
|
|
172
|
+
const entries = [];
|
|
173
|
+
for (const name of await listNames(path.join(root, SKILLS_DIR))) {
|
|
174
|
+
const dir = `${SKILLS_DIR}/${name}`;
|
|
175
|
+
const rels = (await readdir(path.join(root, dir), { recursive: true, withFileTypes: true }))
|
|
176
|
+
.filter((e) => e.isFile())
|
|
177
|
+
.map((e) => path.relative(path.join(root, dir), path.join(e.parentPath, e.name)).split(path.sep).join('/'))
|
|
178
|
+
.sort(compareBytes);
|
|
179
|
+
if (!rels.includes('SKILL.md'))
|
|
180
|
+
throw new Error(`${dir}: нет SKILL.md`);
|
|
181
|
+
const files = [];
|
|
182
|
+
let title = '';
|
|
183
|
+
for (const rel of rels) {
|
|
184
|
+
const content = await readFile(path.join(root, dir, rel), 'utf8');
|
|
185
|
+
if (rel === 'SKILL.md')
|
|
186
|
+
title = nameAndTitle(content, name, `${dir}/SKILL.md`);
|
|
187
|
+
files.push({ path: `${dir}/${rel}`, sha256: sha256(content) });
|
|
188
|
+
}
|
|
189
|
+
const manifest = files.map((f, i) => `${f.sha256} ${rels[i] ?? ''}\n`).join('');
|
|
190
|
+
entries.push({ kind: 'skill', key: name, path: dir, sha256: sha256(manifest), applicableTo: [], patterns: [], title, files });
|
|
191
|
+
}
|
|
192
|
+
return entries;
|
|
193
|
+
}
|
|
194
|
+
/** Субагент — `agents/<name>.md`: frontmatter name, description, skills, tools + инструкция. */
|
|
195
|
+
async function collectAgents(root) {
|
|
196
|
+
const entries = [];
|
|
197
|
+
for (const entry of await listNames(path.join(root, AGENTS_DIR))) {
|
|
198
|
+
if (!entry.endsWith('.md'))
|
|
199
|
+
continue;
|
|
200
|
+
const file = `${AGENTS_DIR}/${entry}`;
|
|
201
|
+
const content = await readFile(path.join(root, file), 'utf8');
|
|
202
|
+
const title = nameAndTitle(content, entry.slice(0, -3), file);
|
|
203
|
+
entries.push({ kind: 'agent', key: entry.slice(0, -3), path: file, sha256: sha256(content), applicableTo: [], patterns: [], title });
|
|
204
|
+
}
|
|
205
|
+
return entries;
|
|
206
|
+
}
|
|
207
|
+
const flowList = (items) => `[${items.join(', ')}]`;
|
|
208
|
+
/** Скаляр через YAML-сериализатор: кавычки появятся, только если без них YAML сломается. */
|
|
209
|
+
const scalar = (value) => stringify(value, { lineWidth: 0 }).trimEnd();
|
|
210
|
+
export function renderIndex(entries, options) {
|
|
211
|
+
const timestamp = options.generatedAt.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
212
|
+
const lines = [HEADER, `generated_at: ${timestamp}`];
|
|
213
|
+
for (const group of GROUPS) {
|
|
214
|
+
const items = entries.filter((e) => e.kind === group.kind);
|
|
215
|
+
lines.push('');
|
|
216
|
+
if (items.length === 0) {
|
|
217
|
+
lines.push(`${group.label}: []`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
lines.push(`${group.label}:`);
|
|
221
|
+
for (const e of items) {
|
|
222
|
+
lines.push(` - key: ${e.key}`, ...(e.title === undefined ? [] : [` title: ${scalar(e.title)}`]), ` path: ${e.path}`,
|
|
223
|
+
// У skill raw нет: это каталог, raw — у каждого файла.
|
|
224
|
+
...(e.files === undefined ? [` raw: ${options.rawBase}/${e.path}`] : []), ` sha256: ${e.sha256}`);
|
|
225
|
+
if (e.files !== undefined) {
|
|
226
|
+
lines.push(' files:');
|
|
227
|
+
for (const f of e.files) {
|
|
228
|
+
lines.push(` - path: ${f.path}`, ` raw: ${options.rawBase}/${f.path}`, ` sha256: ${f.sha256}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (e.patterns.length > 0)
|
|
232
|
+
lines.push(` patterns: ${flowList(e.patterns)}`);
|
|
233
|
+
if (e.kind === 'module' && e.applicableTo.length > 0) {
|
|
234
|
+
lines.push(` applicable_to: ${flowList(e.applicableTo)}`);
|
|
235
|
+
}
|
|
236
|
+
if (e.feature !== undefined) {
|
|
237
|
+
lines.push(' feature:', ` summary: ${scalar(e.feature.summary)}`, ` default: ${scalar(e.feature.default)}`, ` requires: ${flowList(e.feature.requires)}`, ` conflicts: ${flowList(e.feature.conflicts)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return `${lines.join('\n')}\n`;
|
|
242
|
+
}
|
|
243
|
+
/** Убирает строку `generated_at`: она меняется на каждой сборке. */
|
|
244
|
+
export function stripTimestamp(index) {
|
|
245
|
+
return normalizeEol(index)
|
|
246
|
+
.split('\n')
|
|
247
|
+
.filter((line) => !line.startsWith('generated_at:'))
|
|
248
|
+
.join('\n');
|
|
249
|
+
}
|
|
250
|
+
/** Построчное сравнение без учёта `generated_at`; пустой массив — индексы совпадают. */
|
|
251
|
+
export function diffIndex(expected, actual) {
|
|
252
|
+
const exp = stripTimestamp(expected).split('\n');
|
|
253
|
+
const act = stripTimestamp(actual).split('\n');
|
|
254
|
+
const diffs = [];
|
|
255
|
+
for (let i = 0; i < Math.max(exp.length, act.length); i++) {
|
|
256
|
+
if (exp[i] !== act[i])
|
|
257
|
+
diffs.push({ line: i + 1, expected: exp[i], actual: act[i] });
|
|
258
|
+
}
|
|
259
|
+
return diffs;
|
|
260
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shahimat/harness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Harness для проектов на контрактах ai-prompts: генераторы и проверки",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Gevorg Nakhapetyan",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Shahimat/ai-prompts.git",
|
|
10
|
+
"directory": "harness/packages/harness"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Shahimat/ai-prompts/tree/main/harness/packages/harness#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Shahimat/ai-prompts/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"ai-agents",
|
|
18
|
+
"agents-md",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"codex",
|
|
21
|
+
"docs-as-code",
|
|
22
|
+
"changelog",
|
|
23
|
+
"lint"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=24"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"harness": "dist/bin.js"
|
|
31
|
+
},
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/index.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/bin.js', 0o755)\"",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"yaml": "^2.9.1"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^24.19.2",
|
|
53
|
+
"typescript": "^7.0.2",
|
|
54
|
+
"vitest": "^5.0.4"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
}
|
|
59
|
+
}
|