@dzhechkov/harness-core 0.3.142 → 0.3.144

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/src/guard.ts CHANGED
@@ -56,6 +56,110 @@ export interface GuardFacts {
56
56
  readonly skillPacks?: readonly { readonly name: string; readonly nonRegistrable: readonly string[] }[];
57
57
  /** for readme-first: per publishable package, is a version bump staged without a README change? */
58
58
  readonly readmeFirst?: readonly { readonly name: string; readonly versionBumped: boolean; readonly readmeChanged: boolean }[];
59
+ /**
60
+ * for lockfile-in-sync: what each workspace package DECLARES vs what pnpm-lock.yaml RECORDS for that
61
+ * importer. `parsed:false` (or the fact absent) ⇒ the rule reports nothing — fail-open by construction,
62
+ * because a lockfile we could not read is not evidence of a defect.
63
+ */
64
+ readonly lockfile?: {
65
+ readonly parsed: boolean;
66
+ readonly importers?: readonly {
67
+ /** importer path as pnpm keys it, e.g. `packages/@dzhechkov/harness-cli`. */
68
+ readonly importer: string;
69
+ /** the `@dzhechkov/*` specs the package.json declares (deps + devDeps). */
70
+ readonly declared: Readonly<Record<string, string>>;
71
+ /** the specs pnpm-lock.yaml records for this importer; `undefined` ⇒ the importer is absent. */
72
+ readonly locked?: Readonly<Record<string, string>> | undefined;
73
+ }[];
74
+ };
75
+ }
76
+
77
+ /** The lowest `lockfileVersion` whose importers carry the `specifier:`/`version:` pair this parser reads. */
78
+ export const MIN_RECOGNISED_LOCKFILE_VERSION = 9;
79
+
80
+ /**
81
+ * RECOGNISE-OR-REFUSE `pnpm-lock.yaml` importers parser — PURE, no YAML dependency. Reads exactly one
82
+ * shape: `importers:` → `<path>:` → `<section>:` → `'<dep>':` → `specifier: <value>` (lockfileVersion 9+).
83
+ *
84
+ * "Tolerant" must mean *refuses to guess*, NOT *guesses quietly*. A half-parse is the dangerous outcome:
85
+ * a lockfileVersion-6 file lists deps as `dep: version` one-liners under a separate `specifiers:` map, so
86
+ * a lenient reader finds the importer KEYS, records ZERO specifiers, and the rule then reports every real
87
+ * dependency as *"not recorded in pnpm-lock.yaml"* — a false-positive storm dressed up as fail-open. So we
88
+ * return `undefined` (⇒ the rule reports NOTHING) unless every one of these holds:
89
+ * 1. `lockfileVersion` is present and ≥ {@link MIN_RECOGNISED_LOCKFILE_VERSION};
90
+ * 2. an `importers:` section exists and yields at least one importer;
91
+ * 3. no legacy inline `dep: value` line appears at dependency depth (the v5/v6 shape);
92
+ * 4. at least one `specifier:` was read, and NO importer came out empty (a truncated file, or a shape
93
+ * we do not understand, always trips this).
94
+ *
95
+ * `dependencies` and `devDependencies` are merged: a dep appears in only one of them per importer, and
96
+ * the rule compares specifier strings only.
97
+ */
98
+ export function parsePnpmLockImporters(lockText: unknown): Record<string, Record<string, string>> | undefined {
99
+ if (typeof lockText !== 'string' || lockText === '') return undefined;
100
+
101
+ // (1) version gate — the ONLY layout this parser claims to understand.
102
+ const versionLine = lockText.match(/^lockfileVersion:\s*['"]?([0-9]+(?:\.[0-9]+)?)['"]?\s*$/m);
103
+ const version = versionLine?.[1] !== undefined ? Number.parseFloat(versionLine[1]) : Number.NaN;
104
+ if (!Number.isFinite(version) || version < MIN_RECOGNISED_LOCKFILE_VERSION) return undefined;
105
+
106
+ const importers: Record<string, Record<string, string>> = {};
107
+ let inImporters = false;
108
+ let current: string | undefined;
109
+ let currentDep: string | undefined;
110
+ let specifiersSeen = 0;
111
+ let sawImportersKey = false;
112
+ for (const line of lockText.split('\n')) {
113
+ if (/^importers:\s*$/.test(line)) {
114
+ inImporters = true;
115
+ sawImportersKey = true;
116
+ continue;
117
+ }
118
+ if (!inImporters) continue;
119
+ if (/^\S/.test(line)) break; // a new top-level key ends the importers section
120
+ if (line.trim() === '') continue;
121
+ const importer = line.match(/^ {2}(\S.*?):\s*$/);
122
+ if (importer && importer[1] !== undefined) {
123
+ current = unquoteYaml(importer[1]);
124
+ importers[current] = importers[current] ?? {};
125
+ currentDep = undefined;
126
+ continue;
127
+ }
128
+ if (current === undefined) continue;
129
+ // (3) a dependency-depth line that carries an INLINE value is the pre-v9 shape → refuse outright
130
+ // rather than silently recording nothing for this importer.
131
+ if (/^ {6}\S.*?:\s+\S/.test(line)) return undefined;
132
+ const dep = line.match(/^ {6}(\S.*?):\s*$/);
133
+ if (dep && dep[1] !== undefined) {
134
+ // A dep line while the PREVIOUS dep never got its specifier = a truncated/unrecognized shape —
135
+ // refuse the whole parse rather than warn on a half-read (Codex re-QE: pending currentDep).
136
+ if (currentDep !== undefined) return undefined;
137
+ currentDep = unquoteYaml(dep[1]);
138
+ continue;
139
+ }
140
+ const spec = line.match(/^ {8}specifier:\s*(.+?)\s*$/);
141
+ if (spec && spec[1] !== undefined && currentDep !== undefined) {
142
+ importers[current]![currentDep] = unquoteYaml(spec[1]);
143
+ specifiersSeen += 1;
144
+ currentDep = undefined;
145
+ }
146
+ }
147
+ // EOF with a dep still awaiting its specifier: truncated — refuse, never warn on a half-parse.
148
+ if (currentDep !== undefined) return undefined;
149
+
150
+ // (2) + (4) structural confidence: no importers, no specifiers, or ANY importer that came out empty
151
+ // (truncation, an unread section shape) means we did not really parse this file — report nothing.
152
+ if (!sawImportersKey || Object.keys(importers).length === 0 || specifiersSeen === 0) return undefined;
153
+ for (const deps of Object.values(importers)) if (Object.keys(deps).length === 0) return undefined;
154
+ return importers;
155
+ }
156
+
157
+ function unquoteYaml(s: string): string {
158
+ const t = s.trim();
159
+ if ((t.startsWith("'") && t.endsWith("'") && t.length >= 2) || (t.startsWith('"') && t.endsWith('"') && t.length >= 2)) {
160
+ return t.slice(1, -1);
161
+ }
162
+ return t;
59
163
  }
60
164
 
61
165
  /** The built-in rule set (works with no config). Ops are the mutating operations each rule guards. */
@@ -66,6 +170,7 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
66
170
  { id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' },
67
171
  { id: 'skills-registrable', severity: 'soft', ops: ['publish'], description: 'every skill directory in a skill pack has a depth-1 SKILL.md (a buried or missing one ships un-registrable — the health-advisor 1.2.0 class)' },
68
172
  { id: 'readme-first', severity: 'soft', ops: ['publish'], description: 'a package with a staged version bump must update its own README.md in the same change (README-first)' },
173
+ { id: 'lockfile-in-sync', severity: 'soft', ops: ['publish'], description: 'every workspace @dzhechkov/* dependency spec matches the specifier pnpm-lock.yaml records for that importer (a dep bump without a lockfile refresh breaks CI with ERR_PNPM_OUTDATED_LOCKFILE). SOFT-ONLY — a config cannot promote it to HARD' },
69
174
  { id: 'store-bloat-cap', severity: 'soft', ops: ['teach', 'consolidate'], description: 'the learned store is within its size cap' },
70
175
  ];
71
176
 
@@ -154,6 +259,40 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
154
259
  }
155
260
  return out;
156
261
  },
262
+ 'lockfile-in-sync': (f, _sev) => {
263
+ // The 2026-07-28 CI break, mechanized: an overnight dep bump edited package.json and left
264
+ // pnpm-lock.yaml stale, so `pnpm install --frozen-lockfile` died with ERR_PNPM_OUTDATED_LOCKFILE.
265
+ // SOFT + FAIL-OPEN: no lockfile evidence ⇒ NO violation. A guard that cannot read the lockfile must
266
+ // never invent one — a false block on publish costs more than the miss it prevents.
267
+ // The injected severity is IGNORED on purpose (MED-6): this rule emits `soft` unconditionally, so
268
+ // neither a config promotion nor a hand-built rules array can turn a tolerant parser into a blocker.
269
+ const sev: GuardSeverity = 'soft';
270
+ const lock = f.lockfile;
271
+ if (!lock || typeof lock !== 'object' || lock.parsed !== true || !Array.isArray(lock.importers)) return [];
272
+ const out: Violation[] = [];
273
+ for (const imp of lock.importers) {
274
+ if (!imp || typeof imp.importer !== 'string' || !imp.declared || typeof imp.declared !== 'object') continue;
275
+ const declared = Object.entries(imp.declared).filter(([dep, spec]) => dep.startsWith('@dzhechkov/') && typeof spec === 'string');
276
+ if (declared.length === 0) continue;
277
+ if (imp.locked === undefined || imp.locked === null) {
278
+ out.push({
279
+ rule: 'lockfile-in-sync',
280
+ severity: sev,
281
+ detail: `${imp.importer}: declares ${declared.length} @dzhechkov/* dep(s) but has no importer entry in pnpm-lock.yaml — run \`pnpm install\` (CI installs with --frozen-lockfile)`,
282
+ });
283
+ continue;
284
+ }
285
+ for (const [dep, spec] of declared) {
286
+ const locked = imp.locked[dep];
287
+ if (locked === undefined) {
288
+ out.push({ rule: 'lockfile-in-sync', severity: sev, detail: `${imp.importer}: ${dep} = "${spec}" is not recorded in pnpm-lock.yaml — run \`pnpm install\` (CI installs with --frozen-lockfile)` });
289
+ } else if (locked !== spec) {
290
+ out.push({ rule: 'lockfile-in-sync', severity: sev, detail: `${imp.importer}: ${dep} = "${spec}" in package.json but "${locked}" in pnpm-lock.yaml — run \`pnpm install\` to refresh the lockfile (CI installs with --frozen-lockfile)` });
291
+ }
292
+ }
293
+ }
294
+ return out;
295
+ },
157
296
  'store-bloat-cap': (f, sev) => {
158
297
  const s = f.store;
159
298
  if (!s || !Number.isFinite(s.count) || !Number.isFinite(s.cap) || s.cap <= 0) return [];
@@ -161,6 +300,14 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
161
300
  },
162
301
  };
163
302
 
303
+ /**
304
+ * Rules that may NEVER be promoted to HARD, whatever a config says. A rule whose evidence comes from a
305
+ * deliberately tolerant parser must not be able to BLOCK an operation: the parser's own design admits it
306
+ * may not understand a file, and "I might be wrong" plus "block the publish" is the wrong pair. Disabling
307
+ * such a rule stays allowed — only the promotion is refused.
308
+ */
309
+ export const SOFT_ONLY_RULES: readonly string[] = ['lockfile-in-sync'];
310
+
164
311
  /** Merge a user config over the defaults: override severity, disable (enabled:false), never add an un-checked rule. */
165
312
  export function resolveRules(userRules?: readonly Partial<GuardRule>[]): GuardRule[] {
166
313
  const byId = new Map<string, GuardRule>(DEFAULT_RULES.map((r) => [r.id, r]));
@@ -168,9 +315,12 @@ export function resolveRules(userRules?: readonly Partial<GuardRule>[]): GuardRu
168
315
  if (!u || typeof u.id !== 'string') continue;
169
316
  const base = byId.get(u.id);
170
317
  if (!base) continue; // a config rule with no built-in checker is ignored (fail-safe: no un-enforceable rules)
318
+ // A SOFT-ONLY rule keeps its severity even when the config asks for hard (see SOFT_ONLY_RULES).
319
+ const severity = u.severity === 'hard' || u.severity === 'soft' ? u.severity : undefined;
320
+ const allowedSeverity = severity !== undefined && !(severity === 'hard' && SOFT_ONLY_RULES.includes(u.id)) ? severity : undefined;
171
321
  byId.set(u.id, {
172
322
  ...base,
173
- ...(u.severity === 'hard' || u.severity === 'soft' ? { severity: u.severity } : {}),
323
+ ...(allowedSeverity !== undefined ? { severity: allowedSeverity } : {}),
174
324
  ...(typeof u.enabled === 'boolean' ? { enabled: u.enabled } : {}),
175
325
  });
176
326
  }
package/src/index.ts CHANGED
@@ -327,6 +327,58 @@ export * from './skills-verify.js';
327
327
  // finding, not a pass).
328
328
  export * from './compounding.js';
329
329
 
330
+ // Cold-vs-warm EPOCH RUNNER (feature epoch-replay, scout idea #4) — the RESULT leg to compounding's
331
+ // readiness leg. Orchestrates + scores; never calls a model. SUPPORTED requires two DISJOINT Wilson
332
+ // intervals; INCONCLUSIVE is a first-class honest outcome.
333
+ // NOTE: `replayableInstances` / `ReplayInstance` are OWNED by compounding.js and merely re-exported
334
+ // there, so this star-export must not re-export them again (duplicate-export error).
335
+ export {
336
+ WILSON_Z,
337
+ MIN_INSTANCES,
338
+ FALSIFY_NO_LIFT_MIN_N,
339
+ NO_LIFT_MARGIN,
340
+ MARGIN_MAX,
341
+ MARGIN_MIN_EXCLUSIVE,
342
+ DIGEST_HONEST_SCOPE,
343
+ WORK_ORDER_KIND,
344
+ WORK_ORDER_VERSION,
345
+ DEFAULT_WORD_MIN,
346
+ DEFAULT_WORD_MAX,
347
+ DEFAULT_MOCK_N,
348
+ DEFAULT_MOCK_SEED,
349
+ wilsonInterval,
350
+ liftInterval,
351
+ corpusFingerprint,
352
+ isValidMargin,
353
+ workOrderDigest,
354
+ verifyWorkOrder,
355
+ buildWorkOrder,
356
+ buildJudgePrompts,
357
+ unblindJudgments,
358
+ scoreEpochReplay,
359
+ generateMockOutcomes,
360
+ renderEpochReplayResult,
361
+ renderWorkOrderSummary,
362
+ renderJudgePromptsSummary,
363
+ type WilsonInterval,
364
+ type LiftInterval,
365
+ type WorkOrder,
366
+ type WorkOrderItem,
367
+ type WorkOrderOptions,
368
+ type WorkOrderVerification,
369
+ type JudgePrompt,
370
+ type JudgePromptsResult,
371
+ type Arm,
372
+ type EpochOutcome,
373
+ type Judgment,
374
+ type UnblindResult,
375
+ type EpochVerdict,
376
+ type ArmResult,
377
+ type EpochReplayResult,
378
+ type ScoreOptions,
379
+ type MockOptions,
380
+ } from './epoch-replay.js';
381
+
330
382
  // Run-process scorecard (feature dz-score, Reading C) — scores the DISCIPLINE of a feature-adr run
331
383
  // from its artifacts. Descriptive-only, permanently: it never gates.
332
384
  export * from './score.js';