@polydeukes/core 0.3.0 → 0.5.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/dist/config.js CHANGED
@@ -1,19 +1,16 @@
1
1
  /**
2
- * Config schema v2 + `defineConfig()` validator — config as data (CONFIG-04).
2
+ * Config schema and the `defineConfig()` validator — config as data.
3
3
  *
4
- * This is the single settings surface the three areas share (covenant's `protectedPaths`,
5
- * ledger's `testCmd`, memory's ticket pattern all reference this shape). Since schema v2 the
6
- * input is pure JSON-representable data: `testCmd` is a `{scope}` template string, and
7
- * `defineConfig` is the runtime validator for parsed unknown data (the CONFIG-03 loader feeds
8
- * it values the compiler never saw). It stays a pure function — zero file I/O, zero runtime
9
- * dependencies (hand-rolled validation; the published JSON Schema is a sibling artifact the
10
- * source never reads).
4
+ * The single settings surface the areas share. The input is pure JSON-representable data,
5
+ * and `defineConfig` is the runtime validator for parsed unknown values the compiler never
6
+ * saw. It stays a pure function no file I/O and no runtime dependencies: validation is
7
+ * hand-rolled, and the published JSON Schema is a sibling artifact this source never reads.
11
8
  */
12
9
  import { isPlainObject } from './is-plain-object.js';
13
- /** Conventional default telemetry log path (PRD §4.3) — local-only observation data. */
10
+ /** Conventional default telemetry log path — local-only observation data. */
14
11
  export const DEFAULT_TELEMETRY_LOG_PATH = '.polydeukes/roi.log';
15
12
  /**
16
- * `ConfigValidationError` — raised when a config fails structural validation (PRD §4.3).
13
+ * `ConfigValidationError` — raised when a config fails structural validation.
17
14
  *
18
15
  * The message names the offending field path so the developer sees exactly what is wrong.
19
16
  * This throw is a developer-time error (config authoring), a different axis from the
@@ -25,7 +22,8 @@ export class ConfigValidationError extends Error {
25
22
  this.name = 'ConfigValidationError';
26
23
  }
27
24
  }
28
- /** The exact key vocabulary of each object level anything else is a typo, rejected loudly. */
25
+ /** Labels the assembly reserves for the judging chain's own registrations. */
26
+ const META_COVENANT_LABELS = ['self-mod', 'shell-mod', 'transcript-mod'];
29
27
  const TOP_LEVEL_KEYS = new Set([
30
28
  '$schema',
31
29
  'languages',
@@ -41,6 +39,7 @@ const WITNESS_KEYS = new Set(['token', 'ttlMinutes']);
41
39
  const DISCIPLINE_KEYS = new Set([
42
40
  'id',
43
41
  'why',
42
+ 'enforce',
44
43
  'in',
45
44
  'except',
46
45
  'forbid',
@@ -49,8 +48,10 @@ const DISCIPLINE_KEYS = new Set([
49
48
  'when',
50
49
  'requirePrecedent',
51
50
  ]);
51
+ const DRAFT_KEYS = new Set(['id', 'why', 'draft']);
52
+ const ENFORCE_LEVELS = new Set(['block', 'advise']);
52
53
  const PREDICATE_KEYS = ['forbid', 'immutable', 'forbidCommand', 'requirePrecedent'];
53
- /** Predicate families that `in`/`except` may scope — delta and context (COVENANT-13 §4.1). */
54
+ /** Predicate families that `in`/`except` may scope — delta and context. */
54
55
  const SCOPED_PREDICATE_KEYS = new Set(['forbid', 'requirePrecedent']);
55
56
  /** Throw on the first key outside the allowed vocabulary, naming the key and its location. */
56
57
  function rejectUnknownKeys(record, allowed, location) {
@@ -75,7 +76,7 @@ function isValidGlob(glob) {
75
76
  return false;
76
77
  }
77
78
  /**
78
- * Compile a `{scope}` template into the callable consumers use (PRD §4.2).
79
+ * Compile a `{scope}` template into the callable consumers use.
79
80
  *
80
81
  * Exactly the literal token `{scope}` is substituted, at every occurrence (`replaceAll`
81
82
  * semantics). Other braces (`${VAR}`, `{a,b}`, `awk '{print}'`) are the shell's own
@@ -96,14 +97,13 @@ function rejectUncompilableRegex(pattern, location) {
96
97
  }
97
98
  }
98
99
  /**
99
- * Validate a context-family `requirePrecedent` value (COVENANT-13 §4.1).
100
+ * Validate a context-family `requirePrecedent` value.
100
101
  *
101
102
  * Evidence vocabulary is layered: the container (a flat object holding exactly one
102
103
  * evidence key) is the core's, and so is the `command` key — a shell command is the
103
104
  * agent-crossing surface, fully validated here. Every other key belongs to an adapter,
104
105
  * whose own validator judges the value; the core passes it through verbatim and never
105
- * inspects it (CONFIG-07 layering). An unrecognized evidence key fails closed at
106
- * assembly time, not here.
106
+ * inspects it. An unrecognized evidence key fails closed at assembly time, not here.
107
107
  */
108
108
  function validateRequirePrecedent(evidence, location) {
109
109
  if (!isPlainObject(evidence)) {
@@ -121,14 +121,128 @@ function validateRequirePrecedent(evidence, location) {
121
121
  rejectUncompilableRegex(command, `${location} requirePrecedent.command`);
122
122
  }
123
123
  }
124
+ /** Validate a draft entry and return it as data. */
125
+ function validateDraft(entry, id, location) {
126
+ for (const key of Object.keys(entry)) {
127
+ if (!DRAFT_KEYS.has(key)) {
128
+ // Named as the draft rule, not as an unknown key: `forbid` et al. are legal
129
+ // discipline keys, just not on a draft.
130
+ throw new ConfigValidationError(`${location} allows only id, why, draft on a draft entry (found '${key}')`);
131
+ }
132
+ }
133
+ if (entry.draft !== true) {
134
+ throw new ConfigValidationError(`${location} draft must be the literal true`);
135
+ }
136
+ if (typeof entry.why !== 'string' || entry.why.length === 0) {
137
+ throw new ConfigValidationError(`${location} why must be a non-empty string on a draft entry — the prose is its whole body`);
138
+ }
139
+ return { id, why: entry.why, draft: true };
140
+ }
141
+ /** Validate the family-independent rules of a judged entry and return its predicate key. */
142
+ function validateJudgedHead(entry, location) {
143
+ rejectUnknownKeys(entry, DISCIPLINE_KEYS, location);
144
+ if (entry.why !== undefined && typeof entry.why !== 'string') {
145
+ throw new ConfigValidationError(`${location} why must be a string`);
146
+ }
147
+ if (entry.enforce !== undefined &&
148
+ (typeof entry.enforce !== 'string' || !ENFORCE_LEVELS.has(entry.enforce))) {
149
+ throw new ConfigValidationError(`${location} enforce must be 'block' or 'advise'`);
150
+ }
151
+ const predicates = PREDICATE_KEYS.filter((key) => entry[key] !== undefined);
152
+ if (predicates.length !== 1) {
153
+ throw new ConfigValidationError(`${location} must have exactly one predicate key ` +
154
+ `(forbid | immutable | forbidCommand | requirePrecedent)`);
155
+ }
156
+ const predicate = predicates[0];
157
+ if (!SCOPED_PREDICATE_KEYS.has(predicate) &&
158
+ (entry.in !== undefined || entry.except !== undefined)) {
159
+ throw new ConfigValidationError(`${location} allows in/except only on a forbid or requirePrecedent entry`);
160
+ }
161
+ // `when` is the context family's trigger; on any other family it would be dead data
162
+ // implying a trigger that is never applied.
163
+ if (entry.when !== undefined && predicate !== 'requirePrecedent') {
164
+ throw new ConfigValidationError(`${location} allows when only on a requirePrecedent entry`);
165
+ }
166
+ if (entry.in !== undefined && !isValidGlob(entry.in)) {
167
+ throw new ConfigValidationError(`${location} in must be a non-empty glob or glob array`);
168
+ }
169
+ if (entry.except !== undefined && !isValidGlob(entry.except)) {
170
+ throw new ConfigValidationError(`${location} except must be a non-empty glob or glob array`);
171
+ }
172
+ return predicate;
173
+ }
174
+ function validateForbid(entry, location) {
175
+ const forbid = entry.forbid;
176
+ if (typeof forbid === 'string') {
177
+ // An empty pattern matches at every position, so the entry would break every
178
+ // in-scope change — rejected like every sibling pattern field.
179
+ if (forbid.length === 0) {
180
+ throw new ConfigValidationError(`${location} forbid must be a non-empty string pattern`);
181
+ }
182
+ rejectUncompilableRegex(forbid, `${location} forbid`);
183
+ }
184
+ else if (isPlainObject(forbid)) {
185
+ const keys = Object.keys(forbid);
186
+ if (keys.length !== 1 || keys[0] !== 'added' || typeof forbid.added !== 'string') {
187
+ throw new ConfigValidationError(`${location} forbid object must have exactly one key 'added' with a string pattern`);
188
+ }
189
+ if (forbid.added.length === 0) {
190
+ throw new ConfigValidationError(`${location} forbid.added must be a non-empty string pattern`);
191
+ }
192
+ rejectUncompilableRegex(forbid.added, `${location} forbid.added`);
193
+ }
194
+ else {
195
+ throw new ConfigValidationError(`${location} forbid must be a string pattern or an { added } object`);
196
+ }
197
+ }
198
+ function validateImmutable(entry, location) {
199
+ if (!isValidGlob(entry.immutable)) {
200
+ throw new ConfigValidationError(`${location} immutable must be a non-empty glob or glob array`);
201
+ }
202
+ }
203
+ function validateForbidCommand(entry, location) {
204
+ if (typeof entry.forbidCommand !== 'string') {
205
+ throw new ConfigValidationError(`${location} forbidCommand must be a string pattern`);
206
+ }
207
+ if (entry.forbidCommand.length === 0) {
208
+ // An empty pattern matches every command line — one typo would block every
209
+ // shell call the entry sees.
210
+ throw new ConfigValidationError(`${location} forbidCommand must be a non-empty string pattern`);
211
+ }
212
+ rejectUncompilableRegex(entry.forbidCommand, `${location} forbidCommand`);
213
+ }
214
+ function validateContextEntry(entry, location) {
215
+ if (entry.when !== undefined) {
216
+ if (typeof entry.when !== 'string') {
217
+ throw new ConfigValidationError(`${location} when must be a string pattern`);
218
+ }
219
+ if (entry.when.length === 0) {
220
+ // An empty pattern matches at every position, so the trigger would fire on any
221
+ // file that merely grows — reject it like every sibling pattern field.
222
+ throw new ConfigValidationError(`${location} when must be a non-empty string pattern`);
223
+ }
224
+ rejectUncompilableRegex(entry.when, `${location} when`);
225
+ }
226
+ validateRequirePrecedent(entry.requirePrecedent, location);
227
+ }
228
+ /** One validator per family, keyed by the predicate that selects the family. */
229
+ const PREDICATE_VALIDATORS = {
230
+ forbid: validateForbid,
231
+ immutable: validateImmutable,
232
+ forbidCommand: validateForbidCommand,
233
+ requirePrecedent: validateContextEntry,
234
+ };
124
235
  /**
125
- * Validate the `disciplines` array (COVENANT-10 §4.1). Throws {@link ConfigValidationError}
126
- * naming the offending entry/key; the validated data passes through verbatim.
236
+ * Validate the `disciplines` array and split judged entries from drafts. Throws
237
+ * {@link ConfigValidationError} naming the offending entry/key; the validated data passes
238
+ * through verbatim, in declaration order.
127
239
  */
128
240
  function validateDisciplines(disciplines) {
129
241
  if (!Array.isArray(disciplines)) {
130
242
  throw new ConfigValidationError('disciplines must be an array');
131
243
  }
244
+ const judged = [];
245
+ const drafts = [];
132
246
  const seenIds = new Set();
133
247
  disciplines.forEach((entry, index) => {
134
248
  if (!isPlainObject(entry)) {
@@ -141,99 +255,27 @@ function validateDisciplines(disciplines) {
141
255
  if (seenIds.has(entry.id)) {
142
256
  throw new ConfigValidationError(`${location} duplicates the id of an earlier entry`);
143
257
  }
144
- seenIds.add(entry.id);
145
- rejectUnknownKeys(entry, DISCIPLINE_KEYS, location);
146
- if (entry.why !== undefined && typeof entry.why !== 'string') {
147
- throw new ConfigValidationError(`${location} why must be a string`);
148
- }
149
- const predicates = PREDICATE_KEYS.filter((key) => entry[key] !== undefined);
150
- if (predicates.length !== 1) {
151
- throw new ConfigValidationError(`${location} must have exactly one predicate key ` +
152
- `(forbid | immutable | forbidCommand | requirePrecedent)`);
153
- }
154
- const predicate = predicates[0];
155
- if (!SCOPED_PREDICATE_KEYS.has(predicate) &&
156
- (entry.in !== undefined || entry.except !== undefined)) {
157
- throw new ConfigValidationError(`${location} allows in/except only on a forbid or requirePrecedent entry`);
158
- }
159
- // `when` is the context family's trigger; on any other family it would be dead data
160
- // implying a trigger that is never applied.
161
- if (entry.when !== undefined && predicate !== 'requirePrecedent') {
162
- throw new ConfigValidationError(`${location} allows when only on a requirePrecedent entry`);
163
- }
164
- if (entry.in !== undefined && !isValidGlob(entry.in)) {
165
- throw new ConfigValidationError(`${location} in must be a non-empty glob or glob array`);
258
+ // The three meta-covenant registrations share the telemetry label space with
259
+ // discipline ids; a colliding id would make gain aggregation and any label-keyed
260
+ // reader (pdks explain) ambiguous.
261
+ if (META_COVENANT_LABELS.includes(entry.id)) {
262
+ throw new ConfigValidationError(`${location} id collides with a meta-covenant label`);
166
263
  }
167
- if (entry.except !== undefined && !isValidGlob(entry.except)) {
168
- throw new ConfigValidationError(`${location} except must be a non-empty glob or glob array`);
169
- }
170
- if (predicate === 'forbid') {
171
- const forbid = entry.forbid;
172
- if (typeof forbid === 'string') {
173
- rejectUncompilableRegex(forbid, `${location} forbid`);
174
- }
175
- else if (isPlainObject(forbid)) {
176
- // Only the { added } direction exists before COVENANT-12.
177
- const keys = Object.keys(forbid);
178
- if (keys.length !== 1 || keys[0] !== 'added' || typeof forbid.added !== 'string') {
179
- throw new ConfigValidationError(`${location} forbid object must have exactly one key 'added' with a string pattern`);
180
- }
181
- rejectUncompilableRegex(forbid.added, `${location} forbid.added`);
182
- }
183
- else {
184
- throw new ConfigValidationError(`${location} forbid must be a string pattern or an { added } object`);
185
- }
186
- }
187
- else if (predicate === 'immutable') {
188
- if (!isValidGlob(entry.immutable)) {
189
- throw new ConfigValidationError(`${location} immutable must be a non-empty glob or glob array`);
190
- }
191
- }
192
- else if (predicate === 'forbidCommand') {
193
- if (typeof entry.forbidCommand !== 'string') {
194
- throw new ConfigValidationError(`${location} forbidCommand must be a string pattern`);
195
- }
196
- rejectUncompilableRegex(entry.forbidCommand, `${location} forbidCommand`);
197
- }
198
- else {
199
- if (entry.when !== undefined) {
200
- if (typeof entry.when !== 'string') {
201
- throw new ConfigValidationError(`${location} when must be a string pattern`);
202
- }
203
- if (entry.when.length === 0) {
204
- // An empty pattern matches at every position, so the trigger would fire on any
205
- // file that merely grows — reject it like every sibling pattern field.
206
- throw new ConfigValidationError(`${location} when must be a non-empty string pattern`);
207
- }
208
- rejectUncompilableRegex(entry.when, `${location} when`);
209
- }
210
- validateRequirePrecedent(entry.requirePrecedent, location);
264
+ seenIds.add(entry.id);
265
+ // Selected by the marker's value, so an explicit `draft: undefined` is absence,
266
+ // like every other optional key in this validator.
267
+ if (entry.draft !== undefined) {
268
+ drafts.push(validateDraft(entry, entry.id, location));
269
+ return;
211
270
  }
271
+ const predicate = validateJudgedHead(entry, location);
272
+ PREDICATE_VALIDATORS[predicate](entry, location);
273
+ judged.push(entry);
212
274
  });
213
- return disciplines;
275
+ return { judged, drafts };
214
276
  }
215
- /**
216
- * Validate parsed unknown data as a {@link PolydeukesConfig} and return a
217
- * {@link ResolvedConfig} with defaults filled and templates compiled (PRD §4.3).
218
- * Pure — no file I/O.
219
- *
220
- * Throws {@link ConfigValidationError} (naming the offending field path) when the top level
221
- * is not a plain object, any object level carries an unknown key, `languages` is
222
- * missing/empty, any language's `productionGlob` is missing/empty, any `testCmd` is not a
223
- * non-empty string template, `telemetry.logPath` is not a string, `protectedPaths` carries a
224
- * non-string element, or `adapters` is not a map of plain-object namespaces.
225
- */
226
- export function defineConfig(config) {
227
- if (!isPlainObject(config)) {
228
- throw new ConfigValidationError('config must be a plain object');
229
- }
230
- rejectUnknownKeys(config, TOP_LEVEL_KEYS, 'config');
231
- // `$schema` is an IDE schema reference (CONFIG-03): accepted, type-checked, and
232
- // ignored — it never appears in the resolution output.
233
- if (config.$schema !== undefined && typeof config.$schema !== 'string') {
234
- throw new ConfigValidationError('$schema must be a string');
235
- }
236
- const languages = config.languages;
277
+ /** Validate the `languages` map and compile each profile's `{scope}` template. */
278
+ function validateLanguages(languages) {
237
279
  if (!isPlainObject(languages) || Object.keys(languages).length === 0) {
238
280
  throw new ConfigValidationError('languages must be a non-empty object');
239
281
  }
@@ -258,61 +300,108 @@ export function defineConfig(config) {
258
300
  testCmd: compileTestCmd(profile.testCmd),
259
301
  };
260
302
  }
261
- if (config.protectedPaths !== undefined && !isStringArray(config.protectedPaths)) {
303
+ return resolvedLanguages;
304
+ }
305
+ /** Validate `protectedPaths` — an array of non-empty strings; the data passes through verbatim. */
306
+ function validateProtectedPaths(protectedPaths) {
307
+ if (!isStringArray(protectedPaths)) {
262
308
  throw new ConfigValidationError('protectedPaths must be an array of strings');
263
309
  }
264
- let adapters;
265
- if (config.adapters !== undefined) {
266
- // Array first: the removed directory-list form deserves a migration hint, not a
267
- // generic type error and an EMPTY array must land here too, never pass as a map.
268
- if (Array.isArray(config.adapters) || !isPlainObject(config.adapters)) {
269
- throw new ConfigValidationError('adapters must be an object map of adapter namespaces — the directory-list form ' +
270
- 'was removed; move directories to protectedPaths');
271
- }
272
- for (const [name, namespace] of Object.entries(config.adapters)) {
273
- if (!isPlainObject(namespace)) {
274
- throw new ConfigValidationError(`adapters.${name} must be an object`);
275
- }
276
- }
277
- adapters = config.adapters;
310
+ // An empty element carries no path meaning and would ride along unnoticed next to
311
+ // valid siblings.
312
+ if (protectedPaths.some((path) => path.length === 0)) {
313
+ throw new ConfigValidationError('protectedPaths must not contain an empty string');
278
314
  }
279
- const disciplines = config.disciplines !== undefined ? validateDisciplines(config.disciplines) : undefined;
280
- let logPath;
281
- if (config.telemetry !== undefined) {
282
- if (!isPlainObject(config.telemetry)) {
283
- throw new ConfigValidationError('telemetry must be an object');
284
- }
285
- rejectUnknownKeys(config.telemetry, TELEMETRY_KEYS, 'telemetry');
286
- if (config.telemetry.logPath !== undefined) {
287
- if (typeof config.telemetry.logPath !== 'string') {
288
- throw new ConfigValidationError('telemetry.logPath must be a string');
289
- }
290
- logPath = config.telemetry.logPath;
291
- }
315
+ return protectedPaths;
316
+ }
317
+ /**
318
+ * Validate the `adapters` map — each namespace is a plain object whose contents belong to
319
+ * that adapter's own validator, so they pass through verbatim.
320
+ */
321
+ function validateAdapters(adapters) {
322
+ // Array first: the removed directory-list form deserves a migration hint, not a
323
+ // generic type error and an EMPTY array must land here too, never pass as a map.
324
+ if (Array.isArray(adapters) || !isPlainObject(adapters)) {
325
+ throw new ConfigValidationError('adapters must be an object map of adapter namespaces — the directory-list form ' +
326
+ 'was removed; move directories to protectedPaths');
292
327
  }
293
- let witness;
294
- if (config.witness !== undefined) {
295
- if (!isPlainObject(config.witness)) {
296
- throw new ConfigValidationError('witness must be an object');
328
+ for (const [name, namespace] of Object.entries(adapters)) {
329
+ if (!isPlainObject(namespace)) {
330
+ throw new ConfigValidationError(`adapters.${name} must be an object`);
297
331
  }
298
- rejectUnknownKeys(config.witness, WITNESS_KEYS, 'witness');
299
- const { token, ttlMinutes } = config.witness;
300
- if (typeof token !== 'string' || token.trim().length === 0) {
301
- throw new ConfigValidationError('witness.token must be a non-empty string after trimming');
302
- }
303
- if (typeof ttlMinutes !== 'number' || !(Number.isFinite(ttlMinutes) && ttlMinutes > 0)) {
304
- throw new ConfigValidationError('witness.ttlMinutes must be a finite number greater than 0');
305
- }
306
- witness = { token, ttlMinutes };
307
332
  }
333
+ return adapters;
334
+ }
335
+ /** Validate the `telemetry` section and return its `logPath` (absent stays undefined). */
336
+ function validateTelemetry(telemetry) {
337
+ if (!isPlainObject(telemetry)) {
338
+ throw new ConfigValidationError('telemetry must be an object');
339
+ }
340
+ rejectUnknownKeys(telemetry, TELEMETRY_KEYS, 'telemetry');
341
+ if (telemetry.logPath === undefined) {
342
+ return undefined;
343
+ }
344
+ if (typeof telemetry.logPath !== 'string') {
345
+ throw new ConfigValidationError('telemetry.logPath must be a string');
346
+ }
347
+ if (telemetry.logPath.trim().length === 0) {
348
+ throw new ConfigValidationError('telemetry.logPath must be a non-empty string after trimming');
349
+ }
350
+ return telemetry.logPath;
351
+ }
352
+ /** Validate the `witness` section — both values are consumed at assembly time. */
353
+ function validateWitness(witness) {
354
+ if (!isPlainObject(witness)) {
355
+ throw new ConfigValidationError('witness must be an object');
356
+ }
357
+ rejectUnknownKeys(witness, WITNESS_KEYS, 'witness');
358
+ const { token, ttlMinutes } = witness;
359
+ if (typeof token !== 'string' || token.trim().length === 0) {
360
+ throw new ConfigValidationError('witness.token must be a non-empty string after trimming');
361
+ }
362
+ if (typeof ttlMinutes !== 'number' || !(Number.isFinite(ttlMinutes) && ttlMinutes > 0)) {
363
+ throw new ConfigValidationError('witness.ttlMinutes must be a finite number greater than 0');
364
+ }
365
+ return { token, ttlMinutes };
366
+ }
367
+ /**
368
+ * Validate parsed unknown data as a {@link PolydeukesConfig} and return a
369
+ * {@link ResolvedConfig} with defaults filled and templates compiled. Pure — no file I/O.
370
+ *
371
+ * Throws {@link ConfigValidationError} (naming the offending field path) when the top level
372
+ * is not a plain object, any object level carries an unknown key, `languages` is
373
+ * missing/empty, any language's `productionGlob` is missing/empty, any `testCmd` is not a
374
+ * non-empty string template, `telemetry.logPath` is not a non-empty string after trimming,
375
+ * `protectedPaths` carries a non-string or empty element, or `adapters` is not a map of
376
+ * plain-object namespaces.
377
+ */
378
+ export function defineConfig(config) {
379
+ if (!isPlainObject(config)) {
380
+ throw new ConfigValidationError('config must be a plain object');
381
+ }
382
+ rejectUnknownKeys(config, TOP_LEVEL_KEYS, 'config');
383
+ // `$schema` is an IDE schema reference: accepted, type-checked, and ignored — it never
384
+ // appears in the resolution output.
385
+ if (config.$schema !== undefined && typeof config.$schema !== 'string') {
386
+ throw new ConfigValidationError('$schema must be a string');
387
+ }
388
+ const resolvedLanguages = validateLanguages(config.languages);
389
+ const protectedPaths = config.protectedPaths !== undefined ? validateProtectedPaths(config.protectedPaths) : undefined;
390
+ const adapters = config.adapters !== undefined ? validateAdapters(config.adapters) : undefined;
391
+ const split = config.disciplines !== undefined ? validateDisciplines(config.disciplines) : undefined;
392
+ const disciplines = split?.judged;
393
+ const drafts = split !== undefined && split.drafts.length > 0 ? split.drafts : undefined;
394
+ const logPath = config.telemetry !== undefined ? validateTelemetry(config.telemetry) : undefined;
395
+ const witness = config.witness !== undefined ? validateWitness(config.witness) : undefined;
308
396
  return {
309
397
  languages: resolvedLanguages,
310
- ...(config.protectedPaths !== undefined && { protectedPaths: config.protectedPaths }),
398
+ ...(protectedPaths !== undefined && { protectedPaths }),
311
399
  ...(adapters !== undefined && { adapters }),
312
400
  telemetry: {
313
401
  logPath: logPath ?? DEFAULT_TELEMETRY_LOG_PATH,
314
402
  },
315
403
  ...(disciplines !== undefined && { disciplines }),
404
+ ...(drafts !== undefined && { drafts }),
316
405
  ...(witness !== undefined && { witness }),
317
406
  };
318
407
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * exit-codes — the covenant protocol's exit-code vocabulary.
3
+ *
4
+ * The three codes are distinct and ordered by severity. The covenant *body* only ever
5
+ * emits `0` (uphold) or `1` (break, non-blocking); translating a break into the blocking
6
+ * `2` is the wrapper's job, never the core's. The sole place the core itself reaches for
7
+ * `2` is the fail-closed parse path in the barrel.
8
+ *
9
+ * These live in their own module rather than the barrel because `fail-policy.ts` needs
10
+ * them: importing them from the barrel, which re-exports fail-policy, is an initialization
11
+ * cycle — the constants read as `undefined` depending on which module the runtime evaluates
12
+ * first. The barrel re-exports them, so every consumer outside core still reaches them at
13
+ * the same path.
14
+ */
15
+ /** Promise upheld — no violation, the edit/push passes. */
16
+ export declare const EXIT_UPHOLD = 0;
17
+ /** Violation reported as a non-blocking signal. The covenant body's break code. */
18
+ export declare const EXIT_BREAK_NON_BLOCKING = 1;
19
+ /** Violation blocked — the edit/push is refused. Reserved for the wrapper / fail-closed. */
20
+ export declare const EXIT_BREAK_BLOCKING = 2;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * exit-codes — the covenant protocol's exit-code vocabulary.
3
+ *
4
+ * The three codes are distinct and ordered by severity. The covenant *body* only ever
5
+ * emits `0` (uphold) or `1` (break, non-blocking); translating a break into the blocking
6
+ * `2` is the wrapper's job, never the core's. The sole place the core itself reaches for
7
+ * `2` is the fail-closed parse path in the barrel.
8
+ *
9
+ * These live in their own module rather than the barrel because `fail-policy.ts` needs
10
+ * them: importing them from the barrel, which re-exports fail-policy, is an initialization
11
+ * cycle — the constants read as `undefined` depending on which module the runtime evaluates
12
+ * first. The barrel re-exports them, so every consumer outside core still reaches them at
13
+ * the same path.
14
+ */
15
+ /** Promise upheld — no violation, the edit/push passes. */
16
+ export const EXIT_UPHOLD = 0;
17
+ /** Violation reported as a non-blocking signal. The covenant body's break code. */
18
+ export const EXIT_BREAK_NON_BLOCKING = 1;
19
+ /** Violation blocked — the edit/push is refused. Reserved for the wrapper / fail-closed. */
20
+ export const EXIT_BREAK_BLOCKING = 2;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * fail-policy — the failure-kind → fail-mode policy table (CORE-03).
2
+ * fail-policy — the failure-kind → fail-mode policy table.
3
3
  *
4
4
  * Pure and total: classifying a failure and mapping it to an exit code never
5
5
  * performs I/O and never throws. The single source of truth for "which failures
@@ -8,7 +8,7 @@
8
8
  /** How a failure resolves: 'open' passes the call through, 'closed' blocks it. */
9
9
  export type FailMode = 'open' | 'closed';
10
10
  /**
11
- * The registered failure kinds (PRD §4.1). Gate-integrity failures
11
+ * The registered failure kinds. Gate-integrity failures
12
12
  * (evidence-absence / input-parse / undecidable-structure) fail closed;
13
13
  * observability failures fail open so measurement loss never holds work hostage.
14
14
  */
@@ -16,14 +16,14 @@ export type FailureKind = 'evidence-absence' | 'input-parse' | 'undecidable-stru
16
16
  /**
17
17
  * Resolve a failure kind to its {@link FailMode} via the policy table.
18
18
  *
19
- * fail-closed default (PRD §5.2): any unregistered kind — including '' and
20
- * prototype-pollution keys — resolves to 'closed'. "Cannot classify" means
21
- * block. Pure and total (PRD §7): never throws, no I/O, no logging.
19
+ * fail-closed default: any unregistered kind — including '' and prototype-pollution
20
+ * keys — resolves to 'closed'. "Cannot classify" means block. Pure and total: never
21
+ * throws, no I/O, no logging.
22
22
  */
23
23
  export declare function resolveFailMode(kind: string): FailMode;
24
24
  /**
25
- * Map a {@link FailMode} to the covenant protocol's exit code (PRD §4.2):
25
+ * Map a {@link FailMode} to the covenant protocol's exit code:
26
26
  * 'open' → {@link EXIT_UPHOLD}, 'closed' → {@link EXIT_BREAK_BLOCKING}.
27
- * Reuses CORE-01's constants — no independent numeric literals here.
27
+ * Reuses the protocol's constants — no independent numeric literals here.
28
28
  */
29
29
  export declare function failModeToExitCode(mode: FailMode): 0 | 2;
@@ -1,13 +1,13 @@
1
1
  /**
2
- * fail-policy — the failure-kind → fail-mode policy table (CORE-03).
2
+ * fail-policy — the failure-kind → fail-mode policy table.
3
3
  *
4
4
  * Pure and total: classifying a failure and mapping it to an exit code never
5
5
  * performs I/O and never throws. The single source of truth for "which failures
6
6
  * block and which pass through" lives here, not scattered across call sites.
7
7
  */
8
- import { EXIT_BREAK_BLOCKING, EXIT_UPHOLD } from './index.js';
8
+ import { EXIT_BREAK_BLOCKING, EXIT_UPHOLD } from './exit-codes.js';
9
9
  /**
10
- * Policy table (PRD §4.1). Null-prototype so lookups can never reach
10
+ * Policy table. Null-prototype so lookups can never reach
11
11
  * Object.prototype members ('__proto__', 'toString', …) — those must resolve
12
12
  * to the fail-closed default, not to an inherited function.
13
13
  */
@@ -20,17 +20,17 @@ const FAIL_POLICY = Object.assign(Object.create(null), {
20
20
  /**
21
21
  * Resolve a failure kind to its {@link FailMode} via the policy table.
22
22
  *
23
- * fail-closed default (PRD §5.2): any unregistered kind — including '' and
24
- * prototype-pollution keys — resolves to 'closed'. "Cannot classify" means
25
- * block. Pure and total (PRD §7): never throws, no I/O, no logging.
23
+ * fail-closed default: any unregistered kind — including '' and prototype-pollution
24
+ * keys — resolves to 'closed'. "Cannot classify" means block. Pure and total: never
25
+ * throws, no I/O, no logging.
26
26
  */
27
27
  export function resolveFailMode(kind) {
28
28
  return FAIL_POLICY[kind] ?? 'closed';
29
29
  }
30
30
  /**
31
- * Map a {@link FailMode} to the covenant protocol's exit code (PRD §4.2):
31
+ * Map a {@link FailMode} to the covenant protocol's exit code:
32
32
  * 'open' → {@link EXIT_UPHOLD}, 'closed' → {@link EXIT_BREAK_BLOCKING}.
33
- * Reuses CORE-01's constants — no independent numeric literals here.
33
+ * Reuses the protocol's constants — no independent numeric literals here.
34
34
  */
35
35
  export function failModeToExitCode(mode) {
36
36
  return mode === 'open' ? EXIT_UPHOLD : EXIT_BREAK_BLOCKING;