@polydeukes/core 0.4.0 → 0.6.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.ko.md CHANGED
@@ -14,7 +14,8 @@
14
14
 
15
15
  - **약속(covenant) 프로토콜.** 모든 약속이 말하는 계약입니다. 입력은 stdin-JSON(`CovenantInput`, `parseInput`)으로 들어오고 판정은 exit code로 나갑니다. 약속 본체는 `0`(지켜짐)과 `1`(어겨짐, 비차단)만 내고, `1`을 차단인 `2`로 바꾸는 일은 래퍼의 몫입니다. 파싱은 fail-closed라서 판정할 수 없는 입력은 조용히 통과하는 대신 `2`로 끝납니다. 각 도구 호출은 선택적으로 자기 `fileChange` 증거를 싣습니다. 판별 유니온(discriminated union, `create`/`modify`/`delete`, 삭제도 1급)으로 표현되는 에이전트 중립 증거를 어댑터가 각자의 원천(가상 적용, git blob)에서 채우므로, 델타 판정이 디스크를 만지지 않습니다. 귀속이 필요 없는 소비자는 `allFileChanges`로 평면 순회합니다.
16
16
  - **ROI 텔레메트리.** append 전용 라인 수집기(`appendRecord`, `readRecords`)와 `gain` 집계(`runGain`)입니다. 모든 패키지가 공유 fail-open 래퍼(`appendRecordFailOpen`)를 거쳐 이 수집기 하나로 기록합니다. 관측은 fail-open이라서 기록 실패가 판정을 바꾸지 않습니다.
17
- - **config 스키마 v2, 설정은 데이터.** `defineConfig(unknown)`가 파싱된 yml/json 데이터를 검증합니다. 미지의 키는 이 패키지가 소유하는 층위에서 오류로 거부됩니다. 오타가 규율을 조용히 꺼 버리면 안 되기 때문입니다. 어댑터 네임스페이스 안쪽의 내용은 그 어댑터가 검증합니다. `testCmd`는 `{scope}` 템플릿 문자열이고, 호출형 함수로 컴파일되어 돌아옵니다. 같은 계약의 JSON Schema가 `@polydeukes/core/schema.json`으로 배포되며, 검증기와의 동치는 계약 테스트가 강제합니다. 스키마에 `disciplines:`가 추가됐습니다. 사용자가 선언하는 규율(discipline) 항목(`forbid` / `immutable` / `forbidCommand` / `requirePrecedent`, 항목당 술어 정확히 하나)을 순수 데이터로 검증하고, 컴파일은 covenant 패키지가 맡습니다. `requirePrecedent`의 증거 어휘는 `adapters:`와 같은 방식으로 계층이 나뉩니다. 셸 명령은 에이전트를 넘나드는 표면이라 코어가 `command` 키를 전부 검증하고, 나머지 키는 컨테이너 형태만(키가 정확히 하나인 평면 객체) 검증한 뒤 값은 그대로 넘겨 그 어휘를 가진 어댑터가 판정합니다.
17
+ - **config 스키마 v2, 설정은 데이터.** `defineConfig(unknown)`가 파싱된 yml/json 데이터를 검증합니다. 미지의 키는 이 패키지가 소유하는 층위에서 오류로 거부됩니다. 오타가 규율을 조용히 꺼 버리면 안 되기 때문입니다. 어댑터 네임스페이스 안쪽의 내용은 그 어댑터가 검증합니다. `testCmd`는 `{scope}` 템플릿 문자열이고, 호출형 함수로 컴파일되어 돌아옵니다. 같은 계약의 JSON Schema가 `@polydeukes/core/schema.json`으로 배포되며, 검증기와의 동치는 계약 테스트가 강제합니다. 스키마에 `disciplines:`가 추가됐습니다. 사용자가 선언하는 규율(discipline) 항목(항목마다 `declare` 블록 하나, 또는 `draft`)을 순수 데이터로 검증하고, 컴파일은 covenant 패키지가 맡습니다.
18
+ - **대수 선언 스키마(`ALGEBRA-01`).** `validateAlgebraDeclaration(unknown)`이 선언 하나의 형상을 검사하고 데이터 그대로 돌려줍니다. 판정은 `judge = relate ∘ extract`이고 블록은 `scope` · `supply` · `extract` · `relate` · `witness` 다섯입니다. 관계(relation) 자리는 일곱 이름으로, 이진 결합자(combinator) 자리는 셋으로 닫혀 있고, 단항 추출(extract) 어휘는 열려 있습니다. 같은 계약의 JSON Schema가 `@polydeukes/core/algebra-declaration.schema.json`으로 배포되며 동치는 같은 방식의 계약 테스트가 강제합니다. 코어는 형상만 검증합니다. 추출을 실행하지도 관계를 평가하지도 않으며, 엔진(`ALGEBRA-02`)이 서기 전까지 `disciplines:`는 이 블록을 받지 않습니다.
18
19
  - **실패 정책 테이블.** 실패 유형별 fail-open과 fail-closed를 테이블 하나(`resolveFailMode`)가 정합니다. "판정 불가"는 언제나 차단입니다.
19
20
  - **보호 경로 정규화.** `normalizeProtectedPaths`가 선언된 `protectedPaths` 목록을 디스패처가 대조하는 리터럴 경로 문자열로 다듬습니다(공백 정리, 접두·접미 제거, 중복 제거). 어댑터 설정은 `adapters:` 네임스페이스 맵에 삽니다. 어댑터마다 객체 하나이며, 내용의 검증은 그 어댑터의 몫이고 코어는 그대로 넘깁니다.
20
21
  - **정규 대화 기록(canonical transcript) 이음새(seam).** 약속이 세션 이력을 물을 때 쓰는 질의 인터페이스 `CanonicalTranscript`입니다. 서브에이전트 호출, 사용자 메시지, 도구 호출(`findToolCalls`, 이름과 인자 모두 어댑터가 채우는 값)을 묻습니다. 기본값은 noop이라 주입받지 못한 소비자는 "아무 일도 없었다"로 수렴하고, 실제 대화 기록은 어댑터 뒤에 있습니다.
package/README.md CHANGED
@@ -14,7 +14,8 @@ Every unit below is landed and tested — not blueprint:
14
14
 
15
15
  - **Covenant protocol** — the contract every covenant (a deterministic, mutually binding promise) speaks: input arrives as stdin-JSON (`CovenantInput`, `parseInput`), verdicts leave as exit codes. A covenant body only ever emits `0` (upheld) or `1` (broken, non-blocking); translating `1` into the blocking `2` is the wrapper's job. Parsing is fail-closed — an unjudgeable payload resolves to `2`, never to a silent pass. Each tool call optionally carries its own `fileChange` — agent-neutral evidence as a discriminated union (`create`/`modify`/`delete`, deletion first-class) adapters fill from their own sources (virtual apply, git blobs) so delta judgments never touch disk; `allFileChanges` flattens it for consumers that need no attribution.
16
16
  - **ROI telemetry** — a single append-only, line-oriented collector (`appendRecord`, `readRecords`) plus the `gain` aggregation (`runGain`). Every package writes through this one collector — via `appendRecordFailOpen`, the shared fail-open wrapper. Observation is fail-open: a logging failure never changes a verdict.
17
- - **Config schema v2 (config as data)** — `defineConfig(unknown)` validates parsed yml/json data. Unknown keys are rejected loudly at the level this package owns (a typo must not silently disable a discipline) while an adapter namespace's contents are its own adapter's to validate, `testCmd` is a `{scope}` template string compiled into a callable, and the matching JSON Schema ships as `@polydeukes/core/schema.json` — held equivalent to the validator by a contract test. The schema now includes `disciplines:` — user-declared discipline entries (`forbid` / `immutable` / `forbidCommand` / `requirePrecedent`, exactly one predicate each) validated here as pure data and compiled by the covenant package. `requirePrecedent`'s evidence vocabulary is layered the way `adapters:` is: the core fully validates the `command` key (a shell command is the agent-crossing surface) and validates only the container shape of every other key — a flat object holding exactly one key — passing the value through verbatim for its owning adapter to judge.
17
+ - **Config schema v2 (config as data)** — `defineConfig(unknown)` validates parsed yml/json data. Unknown keys are rejected loudly at the level this package owns (a typo must not silently disable a discipline) while an adapter namespace's contents are its own adapter's to validate, `testCmd` is a `{scope}` template string compiled into a callable, and the matching JSON Schema ships as `@polydeukes/core/schema.json` — held equivalent to the validator by a contract test. The schema now includes `disciplines:` — user-declared discipline entries (one `declare` block each, or a `draft`) validated here as pure data and compiled by the covenant package.
18
+ - **Algebra declaration schema (`ALGEBRA-01`)** — `validateAlgebraDeclaration(unknown)` checks the shape of one declaration (`judge = relate ∘ extract`: `scope` · `sources` · `supply` · `extract` · `relate` · `witness` blocks — `sources` binds a name to a repo-relative file outside the target, its kind position closed to `file`; a relation position closed to seven names, a binary combinator position closed to three, an open unary extraction vocabulary) and returns it as data. The matching JSON Schema ships as `@polydeukes/core/algebra-declaration.schema.json`, held equivalent by the same kind of contract test. The core validates shape only — it runs no extraction and evaluates no relation; `disciplines:` does not accept these blocks until the engine lands (`ALGEBRA-02`).
18
19
  - **Fail policy table** — one table (`resolveFailMode`) decides fail-open vs fail-closed per failure kind; "cannot judge" always means block.
19
20
  - **Protected-path normalization** — `normalizeProtectedPaths` turns the declared `protectedPaths` list into the literal path strings the dispatcher matches on (trim, prefix/suffix strip, dedupe). Adapter settings live in the `adapters:` namespace map — one object per adapter, contents validated by that adapter, passed through the core verbatim.
20
21
  - **Canonical transcript seam** — `CanonicalTranscript` is the query interface covenants use to ask about session history: subagent invocations, user messages, and tool calls (`findToolCalls`, whose name and args are adapter-supplied values). The default is a noop, so an uninjected consumer converges on "nothing happened"; real transcripts stay behind adapters.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `algebra.ts` — the shape of one algebra declaration: five blocks, a closed relation
3
+ * position, a closed binary combinator position, and an open unary extraction vocabulary.
4
+ *
5
+ * The module knows no judgment. It runs no extraction and evaluates no relation; the
6
+ * kernel expansion laws quoted on each relation branch below are comments, never code.
7
+ */
8
+ /** The relation position, closed. This tuple is the single source of the list. */
9
+ export declare const RELATION_NAMES: readonly ['empty', 'nonEmpty', 'equal', 'subset', 'implies', 'ordered', 'unchanged'];
10
+ /** One of the seven relation names — the closed vocabulary of the relation position. */
11
+ export type RelationName = (typeof RELATION_NAMES)[number];
12
+ /** The binary world-combining position, closed. Anything else is a unary step. */
13
+ export declare const BINARY_COMBINATOR_NAMES: readonly ['union', 'onlyIn', 'intersect'];
14
+ /**
15
+ * What a missing source does: refuse the declaration, let it pass unjudged, or read the
16
+ * absence as an empty item list and judge on.
17
+ */
18
+ export declare const SUPPLY_POLICIES: readonly ['error', 'pass', 'empty'];
19
+ /** The kind position of a `sources` entry, closed. Each entry carries exactly one of them. */
20
+ export declare const SOURCE_KINDS: readonly ['file', 'sidecar', 'transcript'];
21
+ /**
22
+ * `RelationDecl` — the relation position of one relate entry, one branch per name.
23
+ *
24
+ * Every name references extract names declared in the same declaration; `equal` is the
25
+ * only two-sided branch. The kernel expansion quoted on each branch is a comment — the
26
+ * engine owns the semantics, this module only the shape.
27
+ */
28
+ export type RelationDecl =
29
+ /** `empty` — the extraction produced no element. The primitive. */
30
+ {
31
+ op: 'empty';
32
+ of: string;
33
+ }
34
+ /** `nonEmpty` — expands to ¬empty. */
35
+ | {
36
+ op: 'nonEmpty';
37
+ of: string;
38
+ }
39
+ /** `equal` — expands to subset in both directions; the only two-sided relation. */
40
+ | {
41
+ op: 'equal';
42
+ of: [string, string];
43
+ }
44
+ /** `subset` — `of` ⊆ `in`. The primitive. */
45
+ | {
46
+ op: 'subset';
47
+ of: string;
48
+ in: string;
49
+ }
50
+ /** `implies` — expands to a subset of the two key projections. */
51
+ | {
52
+ op: 'implies';
53
+ of: string;
54
+ requires: string;
55
+ }
56
+ /** `ordered` — adjacent pairs are monotone; `strict` forbids equal neighbours. */
57
+ | {
58
+ op: 'ordered';
59
+ of: string;
60
+ strict?: boolean;
61
+ }
62
+ /** `unchanged` — expands to equal over the keys the two states share. */
63
+ | {
64
+ op: 'unchanged';
65
+ of: string;
66
+ };
67
+ /** A binary world combinator — the closed step kind that joins two extractions. */
68
+ export type BinaryStep = {
69
+ op: 'union';
70
+ of: [string, string];
71
+ } | {
72
+ op: 'onlyIn';
73
+ of: string;
74
+ notIn: string;
75
+ } | {
76
+ op: 'intersect';
77
+ of: [string, string];
78
+ };
79
+ /** Open vocabulary — a name outside the three combinators; its arguments pass through. */
80
+ export type UnaryStep = {
81
+ op: string;
82
+ [arg: string]: unknown;
83
+ };
84
+ /** One pipeline step: an open unary step or a closed binary combinator. */
85
+ export type ExtractStep = UnaryStep | BinaryStep;
86
+ /** The `extract` block — named pipelines, each a non-empty step list. */
87
+ export type ExtractBlock = Record<string, ExtractStep[]>;
88
+ /** The `scope` block — which calls a declaration applies to, by constant regex over a source. */
89
+ export type ScopeBlock = {
90
+ source: string;
91
+ include?: string[];
92
+ exclude?: string[];
93
+ excludeIgnoreCase?: boolean;
94
+ };
95
+ /** One supply policy — the closed value position of a `supply` entry. */
96
+ export type SupplyPolicy = (typeof SUPPLY_POLICIES)[number];
97
+ /**
98
+ * The `supply` block — per source name, what its absence does: `error` refuses, `pass` skips,
99
+ * `empty` reads the absence as an empty item list and judges on.
100
+ */
101
+ export type SupplyBlock = Record<string, SupplyPolicy>;
102
+ /**
103
+ * The `sources` block — per source name, what outside the target it stands for.
104
+ *
105
+ * A `file` path is repo-relative and the supply layer joins it onto the root, which is why an
106
+ * absolute path and a `..` segment are refused here rather than at read time. A `sidecar`
107
+ * binding names a channel the surface supplies and a `transcript` binding the session's
108
+ * conversation history; the location of either is the host's fact, not the declaration's, so
109
+ * the value is the marker `true` and never a path.
110
+ */
111
+ export type SourcesBlock = Record<string, {
112
+ file: string;
113
+ } | {
114
+ sidecar: true;
115
+ } | {
116
+ transcript: true;
117
+ }>;
118
+ /**
119
+ * `RelateEntry` — one (extract name, relation) pairing with its break text.
120
+ *
121
+ * Exactly one of `message` and `messageBySide`; the latter only on `equal`, the one relation
122
+ * with two sides.
123
+ */
124
+ export type RelateEntry = {
125
+ id: string;
126
+ relation: RelationDecl;
127
+ } & ({
128
+ message: string;
129
+ } | {
130
+ messageBySide: {
131
+ left: string;
132
+ right: string;
133
+ };
134
+ });
135
+ /**
136
+ * `WitnessBlock` — the valve standing after the verdict: its own extract pipelines and relate
137
+ * entries in the same grammar. It sees the body's extract names; the body never sees its.
138
+ */
139
+ export type WitnessBlock = {
140
+ extract?: ExtractBlock;
141
+ relate: RelateEntry[];
142
+ };
143
+ /**
144
+ * `AlgebraDeclaration` — one judgment written as data, `judge = relate ∘ extract`.
145
+ *
146
+ * Pure JSON shape validated by {@link validateAlgebraDeclaration}; `mechanism` names the
147
+ * catalogue entry whose shape the declaration must match.
148
+ */
149
+ export type AlgebraDeclaration = {
150
+ discipline: string;
151
+ mechanism: string;
152
+ scope?: ScopeBlock;
153
+ sources?: SourcesBlock;
154
+ supply?: SupplyBlock;
155
+ extract: ExtractBlock;
156
+ relate: RelateEntry[];
157
+ witness?: WitnessBlock;
158
+ };
159
+ /** One element for which the relation does not hold. The engine fixes the rest of the shape. */
160
+ export type Witness = {
161
+ readonly value: unknown;
162
+ };
163
+ /**
164
+ * An empty list means the relation holds. The order preserves the extraction's input
165
+ * order — the premise on which two surfaces reach the same verdict.
166
+ */
167
+ export type Witnesses = readonly Witness[];
168
+ /**
169
+ * Validate one algebra declaration's shape, returning it unchanged.
170
+ *
171
+ * Every violation throws {@link ConfigValidationError} with a message starting at
172
+ * `location`, so a caller validating many declarations sees which one failed.
173
+ */
174
+ export declare function validateAlgebraDeclaration(input: unknown, location?: string): AlgebraDeclaration;
@@ -0,0 +1,438 @@
1
+ /**
2
+ * `algebra.ts` — the shape of one algebra declaration: five blocks, a closed relation
3
+ * position, a closed binary combinator position, and an open unary extraction vocabulary.
4
+ *
5
+ * The module knows no judgment. It runs no extraction and evaluates no relation; the
6
+ * kernel expansion laws quoted on each relation branch below are comments, never code.
7
+ */
8
+ import { validateMechanism } from './catalogue.js';
9
+ import { isPlainObject } from './is-plain-object.js';
10
+ import { FIXED_SOURCE_NAMES } from './source-names.js';
11
+ import { ConfigValidationError, isNonEmptyString, isStringArray, rejectUncompilableRegex, rejectUnknownKeys, } from './validation.js';
12
+ /** The relation position, closed. This tuple is the single source of the list. */
13
+ export const RELATION_NAMES = [
14
+ 'empty',
15
+ 'nonEmpty',
16
+ 'equal',
17
+ 'subset',
18
+ 'implies',
19
+ 'ordered',
20
+ 'unchanged',
21
+ ];
22
+ /** The binary world-combining position, closed. Anything else is a unary step. */
23
+ export const BINARY_COMBINATOR_NAMES = ['union', 'onlyIn', 'intersect'];
24
+ /**
25
+ * What a missing source does: refuse the declaration, let it pass unjudged, or read the
26
+ * absence as an empty item list and judge on.
27
+ */
28
+ export const SUPPLY_POLICIES = ['error', 'pass', 'empty'];
29
+ /**
30
+ * The paired source name. `empty` is a property of a single source: `state` holds a
31
+ * before/after pair that only `unchanged` reads, and an absent pair is not a pair of empties.
32
+ */
33
+ const PAIRED_SOURCE_NAME = 'state';
34
+ /** The kind position of a `sources` entry, closed. Each entry carries exactly one of them. */
35
+ export const SOURCE_KINDS = ['file', 'sidecar', 'transcript'];
36
+ const DECLARATION_KEYS = new Set([
37
+ 'discipline',
38
+ 'mechanism',
39
+ 'scope',
40
+ 'sources',
41
+ 'supply',
42
+ 'extract',
43
+ 'relate',
44
+ 'witness',
45
+ ]);
46
+ const SCOPE_KEYS = new Set([
47
+ 'source',
48
+ 'include',
49
+ 'exclude',
50
+ 'excludeIgnoreCase',
51
+ ]);
52
+ const RELATE_ENTRY_KEYS = new Set([
53
+ 'id',
54
+ 'relation',
55
+ 'message',
56
+ 'messageBySide',
57
+ ]);
58
+ const MESSAGE_BY_SIDE_KEYS = new Set(['left', 'right']);
59
+ const WITNESS_KEYS = new Set(['extract', 'relate']);
60
+ /** The argument keys each relation branch admits, `op` included. */
61
+ const RELATION_KEYS = {
62
+ empty: new Set(['op', 'of']),
63
+ nonEmpty: new Set(['op', 'of']),
64
+ equal: new Set(['op', 'of']),
65
+ subset: new Set(['op', 'of', 'in']),
66
+ implies: new Set(['op', 'of', 'requires']),
67
+ ordered: new Set(['op', 'of', 'strict']),
68
+ unchanged: new Set(['op', 'of']),
69
+ };
70
+ const COMBINATOR_KEYS = {
71
+ union: new Set(['op', 'of']),
72
+ onlyIn: new Set(['op', 'of', 'notIn']),
73
+ intersect: new Set(['op', 'of']),
74
+ };
75
+ function isCombinatorName(op) {
76
+ return BINARY_COMBINATOR_NAMES.includes(op);
77
+ }
78
+ /** The names a rejection message lists so the author sees what is admitted. */
79
+ function quotedList(names) {
80
+ return names.map((name) => `'${name}'`).join(', ');
81
+ }
82
+ /**
83
+ * The source names a scope regex can be read over: the fixed names whose value is a string,
84
+ * plus this declaration's own `file` bindings. `changes` is a list and `state` a pair, and a
85
+ * channel is the surface's JSON text — a regex over any of them matches nothing, so a
86
+ * declaration scoped on one is refused here rather than answering zero worlds at runtime.
87
+ */
88
+ const STRING_VALUED_FIXED_SOURCES = ['target.path', 'pre', 'post', 'command'];
89
+ function validateScope(scope, sources, location) {
90
+ if (!isPlainObject(scope)) {
91
+ throw new ConfigValidationError(`${location} scope must be an object`);
92
+ }
93
+ rejectUnknownKeys(scope, SCOPE_KEYS, `${location} scope`);
94
+ if (!isNonEmptyString(scope.source)) {
95
+ throw new ConfigValidationError(`${location} scope.source must be a non-empty string`);
96
+ }
97
+ const binding = isPlainObject(sources) ? sources[scope.source] : undefined;
98
+ const isFileSource = isPlainObject(binding) && typeof binding.file === 'string';
99
+ if (!STRING_VALUED_FIXED_SOURCES.includes(scope.source) && !isFileSource) {
100
+ throw new ConfigValidationError(`${location} scope.source '${scope.source}' is not a string-valued source; scope admits ${quotedList(STRING_VALUED_FIXED_SOURCES)}, or a file source`);
101
+ }
102
+ for (const key of ['include', 'exclude']) {
103
+ const patterns = scope[key];
104
+ if (patterns === undefined)
105
+ continue;
106
+ if (!isStringArray(patterns)) {
107
+ throw new ConfigValidationError(`${location} scope.${key} must be an array of strings`);
108
+ }
109
+ for (const pattern of patterns) {
110
+ rejectUncompilableRegex(pattern, `${location} scope.${key}`);
111
+ }
112
+ }
113
+ if (scope.excludeIgnoreCase !== undefined && typeof scope.excludeIgnoreCase !== 'boolean') {
114
+ throw new ConfigValidationError(`${location} scope.excludeIgnoreCase must be a boolean`);
115
+ }
116
+ }
117
+ /**
118
+ * Validate one `sources` entry's path: a repo-relative string the supply layer can join
119
+ * onto the root. `..` is rejected as a whole SEGMENT, so a name that merely contains two
120
+ * dots (`a..b`) stays legal while `a/../b` cannot climb out of the repository.
121
+ */
122
+ function validateSourceFile(file, location) {
123
+ if (!isNonEmptyString(file)) {
124
+ throw new ConfigValidationError(`${location}.file must be a non-empty string`);
125
+ }
126
+ if (file.startsWith('/')) {
127
+ throw new ConfigValidationError(`${location}.file must be a repo-relative path, not '${file}'`);
128
+ }
129
+ if (file.split('/').includes('..')) {
130
+ throw new ConfigValidationError(`${location}.file carries a '..' segment: '${file}'`);
131
+ }
132
+ }
133
+ function validateSources(sources, location) {
134
+ if (!isPlainObject(sources)) {
135
+ throw new ConfigValidationError(`${location}.sources must be an object`);
136
+ }
137
+ for (const [name, entry] of Object.entries(sources)) {
138
+ if (name.length === 0) {
139
+ throw new ConfigValidationError(`${location}.sources carries an empty source name`);
140
+ }
141
+ if (FIXED_SOURCE_NAMES.includes(name)) {
142
+ throw new ConfigValidationError(`${location}.sources.${name} shadows the world's own source of that name — the fixed names are ${quotedList(FIXED_SOURCE_NAMES)}`);
143
+ }
144
+ const where = `${location}.sources.${name}`;
145
+ if (!isPlainObject(entry)) {
146
+ throw new ConfigValidationError(`${where} must be an object naming one kind`);
147
+ }
148
+ const kinds = Object.keys(entry).filter((key) => SOURCE_KINDS.includes(key));
149
+ if (kinds.length !== 1) {
150
+ throw new ConfigValidationError(`${where} must name exactly one kind, one of ${quotedList(SOURCE_KINDS)}`);
151
+ }
152
+ rejectUnknownKeys(entry, new Set(SOURCE_KINDS), where);
153
+ const kind = kinds[0];
154
+ if (kind === 'sidecar' || kind === 'transcript') {
155
+ // The marker carries no information beyond the kind, so anything but the literal
156
+ // `true` would be a value the supply layer has to interpret.
157
+ if (entry[kind] !== true) {
158
+ throw new ConfigValidationError(`${where}.${kind} must be the literal true`);
159
+ }
160
+ continue;
161
+ }
162
+ validateSourceFile(entry.file, where);
163
+ }
164
+ }
165
+ /**
166
+ * Validate the `supply` block: every key names a source this declaration can be missing.
167
+ *
168
+ * The universe of source names is the fixed seven plus whatever `sources` binds, and it is
169
+ * closed, so a key outside it is a name nothing supplies — a misspelling whose policy never
170
+ * applies while the real source falls to the default.
171
+ */
172
+ function validateSupply(supply, sources, location) {
173
+ if (!isPlainObject(supply)) {
174
+ throw new ConfigValidationError(`${location} supply must be an object`);
175
+ }
176
+ const declared = isPlainObject(sources) ? Object.keys(sources) : [];
177
+ for (const [source, policy] of Object.entries(supply)) {
178
+ if (!FIXED_SOURCE_NAMES.includes(source) && !declared.includes(source)) {
179
+ const bindings = declared.length === 0 ? 'and this declaration binds none' : `or ${quotedList(declared)}`;
180
+ throw new ConfigValidationError(`${location} supply names the source '${source}', which is neither one of ${quotedList(FIXED_SOURCE_NAMES)} ${bindings}`);
181
+ }
182
+ if (!SUPPLY_POLICIES.includes(policy)) {
183
+ throw new ConfigValidationError(`${location} supply.${source} is '${String(policy)}' — must be one of ${quotedList(SUPPLY_POLICIES)}`);
184
+ }
185
+ if (policy === 'empty' && source === PAIRED_SOURCE_NAME) {
186
+ throw new ConfigValidationError(`${location} supply.${source}: 'empty' does not apply to the paired source — it holds a before/after pair, and an absent pair is not two empty states`);
187
+ }
188
+ }
189
+ }
190
+ /**
191
+ * Validate one pipeline step and return the extract names it references.
192
+ *
193
+ * Combinator discrimination is name-first, shape-second: a step named by one of the three
194
+ * is read as that combinator whatever its arguments, so a reserved name can never leak into
195
+ * the open unary vocabulary. A name outside the three that references two extractions
196
+ * (an array `of`, or a `notIn`) is an unknown combinator, not a unary step.
197
+ */
198
+ function validateStep(step, location) {
199
+ if (!isPlainObject(step)) {
200
+ throw new ConfigValidationError(`${location} must be an object`);
201
+ }
202
+ if (!isNonEmptyString(step.op)) {
203
+ throw new ConfigValidationError(`${location} op must be a non-empty string`);
204
+ }
205
+ const op = step.op;
206
+ if (!isCombinatorName(op)) {
207
+ if (Array.isArray(step.of) || step.notIn !== undefined) {
208
+ throw new ConfigValidationError(`${location} op '${op}' references two extractions but is not one of ${quotedList(BINARY_COMBINATOR_NAMES)}`);
209
+ }
210
+ return [];
211
+ }
212
+ rejectUnknownKeys(step, COMBINATOR_KEYS[op], `${location} (${op})`);
213
+ if (op === 'onlyIn') {
214
+ if (!isNonEmptyString(step.of) || !isNonEmptyString(step.notIn)) {
215
+ throw new ConfigValidationError(`${location} '${op}' takes a string 'of' and a string 'notIn'`);
216
+ }
217
+ if (step.of === step.notIn) {
218
+ throw new ConfigValidationError(`${location} '${op}' names '${step.of}' on both sides — the result is always empty`);
219
+ }
220
+ return [step.of, step.notIn];
221
+ }
222
+ const pair = step.of;
223
+ if (!isStringArray(pair) || pair.length !== 2 || !pair.every(isNonEmptyString)) {
224
+ throw new ConfigValidationError(`${location} '${op}' takes 'of' as two extract names`);
225
+ }
226
+ const [left, right] = pair;
227
+ if (left === right) {
228
+ throw new ConfigValidationError(`${location} '${op}' names '${left}' on both sides — the result is that extraction itself`);
229
+ }
230
+ return [left, right];
231
+ }
232
+ /** Validate one extract block, returning each pipeline's outgoing references. */
233
+ function validateExtract(extract, location, label) {
234
+ if (!isPlainObject(extract)) {
235
+ throw new ConfigValidationError(`${location} ${label} must be an object`);
236
+ }
237
+ const references = new Map();
238
+ for (const [name, steps] of Object.entries(extract)) {
239
+ const where = `${location} ${label}.${name}`;
240
+ if (!Array.isArray(steps) || steps.length === 0) {
241
+ throw new ConfigValidationError(`${where} must be a non-empty array of steps`);
242
+ }
243
+ const outgoing = [];
244
+ steps.forEach((step, index) => {
245
+ const referenced = validateStep(step, `${where}[${index}]`);
246
+ if (referenced.length > 0 && index !== 0) {
247
+ throw new ConfigValidationError(`${where}[${index}] a combinator stands only as the first step of a pipeline`);
248
+ }
249
+ outgoing.push(...referenced);
250
+ });
251
+ references.set(name, outgoing);
252
+ }
253
+ return references;
254
+ }
255
+ /** Validate the relation and return the extract names it references. */
256
+ function validateRelation(relation, location) {
257
+ if (!isPlainObject(relation)) {
258
+ throw new ConfigValidationError(`${location} relation must be an object`);
259
+ }
260
+ const op = relation.op;
261
+ if (typeof op !== 'string' || !RELATION_NAMES.includes(op)) {
262
+ throw new ConfigValidationError(`${location} relation op is '${String(op)}' — must be one of ${quotedList(RELATION_NAMES)}`);
263
+ }
264
+ const name = op;
265
+ rejectUnknownKeys(relation, RELATION_KEYS[name], `${location} relation (${name})`);
266
+ if (name === 'equal') {
267
+ const pair = relation.of;
268
+ if (!isStringArray(pair) || pair.length !== 2 || !pair.every(isNonEmptyString)) {
269
+ throw new ConfigValidationError(`${location} relation equal takes 'of' as two extract names`);
270
+ }
271
+ const [left, right] = pair;
272
+ if (left === right) {
273
+ throw new ConfigValidationError(`${location} relation equal names '${left}' on both sides — it can never break`);
274
+ }
275
+ return { op: name, refs: [left, right] };
276
+ }
277
+ if (!isNonEmptyString(relation.of)) {
278
+ throw new ConfigValidationError(`${location} relation ${name} needs 'of' as an extract name`);
279
+ }
280
+ const refs = [relation.of];
281
+ if (name === 'subset') {
282
+ if (!isNonEmptyString(relation.in)) {
283
+ throw new ConfigValidationError(`${location} relation subset needs 'in' as an extract name`);
284
+ }
285
+ refs.push(relation.in);
286
+ }
287
+ if (name === 'implies') {
288
+ if (!isNonEmptyString(relation.requires)) {
289
+ throw new ConfigValidationError(`${location} relation implies needs 'requires' as an extract name`);
290
+ }
291
+ refs.push(relation.requires);
292
+ }
293
+ if (name === 'ordered' && relation.strict !== undefined && typeof relation.strict !== 'boolean') {
294
+ throw new ConfigValidationError(`${location} relation ordered strict must be a boolean`);
295
+ }
296
+ return { op: name, refs };
297
+ }
298
+ /** Validate one entry; `ids` accumulates across the body and the witness block. */
299
+ function validateRelateEntry(entry, location, known, ids) {
300
+ if (!isPlainObject(entry)) {
301
+ throw new ConfigValidationError(`${location} must be an object`);
302
+ }
303
+ rejectUnknownKeys(entry, RELATE_ENTRY_KEYS, location);
304
+ if (!isNonEmptyString(entry.id)) {
305
+ throw new ConfigValidationError(`${location} id must be a non-empty string`);
306
+ }
307
+ const id = entry.id;
308
+ if (ids.has(id)) {
309
+ throw new ConfigValidationError(`${location} duplicates the entry id '${id}'`);
310
+ }
311
+ ids.add(id);
312
+ const { op, refs } = validateRelation(entry.relation, `${location} '${id}'`);
313
+ for (const name of refs) {
314
+ if (!known.has(name)) {
315
+ throw new ConfigValidationError(`${location} '${id}' references '${name}', which no extract in scope defines`);
316
+ }
317
+ }
318
+ const hasMessage = entry.message !== undefined;
319
+ const hasBySide = entry.messageBySide !== undefined;
320
+ if (hasMessage === hasBySide) {
321
+ throw new ConfigValidationError(`${location} '${id}' needs exactly one of 'message' and 'messageBySide'`);
322
+ }
323
+ if (hasMessage && !isNonEmptyString(entry.message)) {
324
+ throw new ConfigValidationError(`${location} '${id}' message must be a non-empty string`);
325
+ }
326
+ if (hasBySide) {
327
+ if (op !== 'equal') {
328
+ throw new ConfigValidationError(`${location} '${id}' carries messageBySide on ${op} — only equal has two sides`);
329
+ }
330
+ const bySide = entry.messageBySide;
331
+ if (!isPlainObject(bySide)) {
332
+ throw new ConfigValidationError(`${location} '${id}' messageBySide must be an object`);
333
+ }
334
+ rejectUnknownKeys(bySide, MESSAGE_BY_SIDE_KEYS, `${location} '${id}' messageBySide`);
335
+ if (!isNonEmptyString(bySide.left) || !isNonEmptyString(bySide.right)) {
336
+ throw new ConfigValidationError(`${location} '${id}' messageBySide needs a non-empty 'left' and 'right'`);
337
+ }
338
+ }
339
+ }
340
+ function validateRelate(relate, location, known, ids) {
341
+ if (!Array.isArray(relate) || relate.length === 0) {
342
+ throw new ConfigValidationError(`${location} relate must be a non-empty array`);
343
+ }
344
+ relate.forEach((entry, index) => {
345
+ validateRelateEntry(entry, `${location} relate[${index}]`, known, ids);
346
+ });
347
+ }
348
+ /**
349
+ * Refuse a combinator reference that does not resolve, and any cycle it takes part in.
350
+ *
351
+ * A self-edge is the shortest cycle, so a plain existence check passes it; only the
352
+ * reachability walk below finds either that or a two-pipeline loop.
353
+ */
354
+ function checkExtractGraph(references, known, location) {
355
+ for (const [name, outgoing] of references) {
356
+ for (const target of outgoing) {
357
+ if (!known.has(target)) {
358
+ throw new ConfigValidationError(`${location} '${name}' references '${target}', which no extract defines`);
359
+ }
360
+ }
361
+ }
362
+ const visiting = new Set();
363
+ const settled = new Set();
364
+ const walk = (name) => {
365
+ if (settled.has(name))
366
+ return;
367
+ if (visiting.has(name)) {
368
+ throw new ConfigValidationError(`${location} '${name}' takes part in a reference cycle`);
369
+ }
370
+ visiting.add(name);
371
+ for (const target of references.get(name) ?? []) {
372
+ walk(target);
373
+ }
374
+ visiting.delete(name);
375
+ settled.add(name);
376
+ };
377
+ for (const name of references.keys()) {
378
+ walk(name);
379
+ }
380
+ }
381
+ /**
382
+ * Validate one algebra declaration's shape, returning it unchanged.
383
+ *
384
+ * Every violation throws {@link ConfigValidationError} with a message starting at
385
+ * `location`, so a caller validating many declarations sees which one failed.
386
+ */
387
+ export function validateAlgebraDeclaration(input, location = 'declaration') {
388
+ if (!isPlainObject(input)) {
389
+ throw new ConfigValidationError(`${location} must be an object`);
390
+ }
391
+ rejectUnknownKeys(input, DECLARATION_KEYS, location);
392
+ if (!isNonEmptyString(input.discipline)) {
393
+ throw new ConfigValidationError(`${location} discipline must be a non-empty string`);
394
+ }
395
+ if (!isNonEmptyString(input.mechanism)) {
396
+ throw new ConfigValidationError(`${location} mechanism must be a non-empty string`);
397
+ }
398
+ if (input.sources !== undefined)
399
+ validateSources(input.sources, location);
400
+ if (input.scope !== undefined)
401
+ validateScope(input.scope, input.sources, location);
402
+ if (input.supply !== undefined)
403
+ validateSupply(input.supply, input.sources, location);
404
+ if (input.extract === undefined) {
405
+ throw new ConfigValidationError(`${location} needs an extract block`);
406
+ }
407
+ const bodyReferences = validateExtract(input.extract, location, 'extract');
408
+ const bodyNames = new Set(bodyReferences.keys());
409
+ checkExtractGraph(bodyReferences, bodyNames, `${location} extract`);
410
+ const ids = new Set();
411
+ if (input.relate === undefined) {
412
+ throw new ConfigValidationError(`${location} needs a relate array`);
413
+ }
414
+ validateRelate(input.relate, location, bodyNames, ids);
415
+ if (input.witness !== undefined) {
416
+ const witness = input.witness;
417
+ if (!isPlainObject(witness)) {
418
+ throw new ConfigValidationError(`${location} witness must be an object`);
419
+ }
420
+ rejectUnknownKeys(witness, WITNESS_KEYS, `${location} witness`);
421
+ // The witness sees the body's extract names as well as its own; the body never sees
422
+ // the witness's.
423
+ const witnessReferences = witness.extract === undefined
424
+ ? new Map()
425
+ : validateExtract(witness.extract, location, 'witness.extract');
426
+ for (const name of witnessReferences.keys()) {
427
+ if (bodyNames.has(name)) {
428
+ throw new ConfigValidationError(`${location} witness.extract '${name}' shadows the body extract of the same name — the two blocks share one namespace`);
429
+ }
430
+ }
431
+ const witnessNames = new Set([...bodyNames, ...witnessReferences.keys()]);
432
+ checkExtractGraph(witnessReferences, witnessNames, `${location} witness.extract`);
433
+ validateRelate(witness.relate, `${location} witness`, witnessNames, ids);
434
+ }
435
+ const declaration = input;
436
+ validateMechanism(declaration, location);
437
+ return declaration;
438
+ }