@dzhechkov/harness-core 0.3.144 → 0.3.145
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/.dz-manifest.json +36 -12
- package/dist/guard-promotion.d.ts +364 -0
- package/dist/guard-promotion.d.ts.map +1 -0
- package/dist/guard-promotion.js +760 -0
- package/dist/guard-promotion.js.map +1 -0
- package/dist/guard.d.ts +33 -1
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +57 -3
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/sbom.json +71 -11
- package/src/guard-promotion.ts +958 -0
- package/src/guard.ts +76 -3
- package/src/index.ts +4 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dz guard promote` — lesson → guard-rule PROMOTION with a "win twice to promote" gate.
|
|
3
|
+
*
|
|
4
|
+
* The cost-of-detection ladder says: put every check on the strongest layer that can express it.
|
|
5
|
+
* `dz compounding` MEASURED (2026-07-29) that this repo's learned store is ~82% write-only while the
|
|
6
|
+
* rules that DID reach layer 1 collapsed their own violation rate (no-workspace-star 31→0,
|
|
7
|
+
* readme-first 49→4). This module is the elevator: it moves a lesson from layer 5 (agent memory) to
|
|
8
|
+
* layer 1 (a deterministic rule) — but only when real evidence earns it.
|
|
9
|
+
*
|
|
10
|
+
* Ported from rUv's `@claude-flow/guidance` ADR-G008 (optimizer-promotion-rule, ACCEPTED) +
|
|
11
|
+
* `src/optimizer.ts` / `src/ledger.ts` (`score = frequency * cost`, promotionTracker, two
|
|
12
|
+
* consecutive wins, one loss resets). IMPROVEMENT OVER SOURCE: ADR-G008's own Negative section
|
|
13
|
+
* admits its A/B uses hard-coded SIMULATED reduction percentages. Here a "win" is a REPLAY of the
|
|
14
|
+
* candidate's check over REAL commits — the firings are real or there is no win.
|
|
15
|
+
*
|
|
16
|
+
* PURE: zero imports, zero I/O, no wall clock. Callers inject lessons, existing rules, and the
|
|
17
|
+
* change history; this module only computes. Same facts ⇒ byte-identical report.
|
|
18
|
+
*
|
|
19
|
+
* WHAT THIS DELIBERATELY IS NOT: a rule SYNTHESISER. Rule code is never generated from lesson text —
|
|
20
|
+
* that is layer-4 model judgment wearing layer-1 clothing, and its failure mode is silent. The fixed
|
|
21
|
+
* template vocabulary below is the entire executable surface (ADR-002).
|
|
22
|
+
*/
|
|
23
|
+
export const TEMPLATES = ['pairing-check', 'absence-check', 'format-match'];
|
|
24
|
+
/** Params are well-formed for their template (a hand-edited config cannot smuggle a half-rule in). */
|
|
25
|
+
export function validTemplateParams(template, params) {
|
|
26
|
+
if (typeof template !== 'string' || !TEMPLATES.includes(template))
|
|
27
|
+
return false;
|
|
28
|
+
if (!params || typeof params !== 'object' || Array.isArray(params))
|
|
29
|
+
return false;
|
|
30
|
+
const p = params;
|
|
31
|
+
const str = (k) => typeof p[k] === 'string' && p[k].length > 0 && p[k].length <= MAX_GLOB_LENGTH;
|
|
32
|
+
// Glob-valued params are additionally bounded in WILDCARD DEGREE, so a hand-edited `.dz/guard.json`
|
|
33
|
+
// cannot install a rule whose pattern makes the engine backtrack catastrophically. `mustMatch` is
|
|
34
|
+
// a literal substring, never compiled, so only its length is bounded.
|
|
35
|
+
const glob = (k) => str(k) && isSafeGlob(p[k]);
|
|
36
|
+
if (template === 'pairing-check')
|
|
37
|
+
return glob('when') && glob('requires');
|
|
38
|
+
if (template === 'absence-check')
|
|
39
|
+
return glob('forbid');
|
|
40
|
+
return glob('file') && str('mustMatch');
|
|
41
|
+
}
|
|
42
|
+
// ── Glob matching (tiny, anchored, injection-proof) ──────────────────────────────────────────────
|
|
43
|
+
/**
|
|
44
|
+
* `**` matches any run of characters (including `/`); `*` matches any run WITHOUT `/`. Every other
|
|
45
|
+
* character is regex-escaped, so a lesson-derived token can never become an expression. Anchored at
|
|
46
|
+
* both ends. Never throws.
|
|
47
|
+
*
|
|
48
|
+
* The leading `**/` is OPTIONAL — `**/package.json` matches BOTH `packages/a/package.json` and a
|
|
49
|
+
* root-level `package.json`. A naive `.*` + `/` made the segment mandatory, so every promoted rule
|
|
50
|
+
* silently missed root-level files: the shadow replay of a real 12-commit history scored 0 firings
|
|
51
|
+
* and the candidate WAITED forever, looking like an honest verdict. A false gate is only ever found
|
|
52
|
+
* by RUNNING it — the unit tests were green throughout.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* The most wildcard groups a glob may contain. Our own classifier emits exactly ONE (`**/<token>`),
|
|
56
|
+
* so 2 is already generous; the cap exists because a regex built from `**a**a**a…` backtracks
|
|
57
|
+
* catastrophically (Codex QE MEASURED >10 s on such a pattern). Collapsing adjacent `.*` does NOT
|
|
58
|
+
* fix that — `.*a.*a.*a` is polynomial in the number of groups, so degree is the thing to bound.
|
|
59
|
+
* Refusal is the right answer here: these params come from a classifier we control, and a glob
|
|
60
|
+
* beyond the cap is a hand-edited config, not a promotion.
|
|
61
|
+
*/
|
|
62
|
+
export const MAX_GLOB_WILDCARDS = 2;
|
|
63
|
+
/** Longest path a glob is matched against; beyond this the input is not a repo path. */
|
|
64
|
+
export const MAX_GLOB_PATH_LENGTH = 4096;
|
|
65
|
+
/** Longest glob accepted. Mirrors the length bound in {@link validTemplateParams}. */
|
|
66
|
+
export const MAX_GLOB_LENGTH = 200;
|
|
67
|
+
/** Collapse `***`/`****`/… runs to `**`, so padding cannot inflate the wildcard count. */
|
|
68
|
+
export function normalizeGlob(glob) {
|
|
69
|
+
return glob.replace(/\*{2,}/g, '**');
|
|
70
|
+
}
|
|
71
|
+
/** How many wildcard groups (`**` or `*`) a NORMALIZED glob contains. */
|
|
72
|
+
export function globWildcardCount(glob) {
|
|
73
|
+
if (typeof glob !== 'string')
|
|
74
|
+
return 0;
|
|
75
|
+
return (normalizeGlob(glob).match(/\*\*|\*/g) ?? []).length;
|
|
76
|
+
}
|
|
77
|
+
/** A glob this module is willing to compile: bounded length AND bounded wildcard degree. */
|
|
78
|
+
export function isSafeGlob(glob) {
|
|
79
|
+
return typeof glob === 'string' && glob.length > 0 && glob.length <= MAX_GLOB_LENGTH && globWildcardCount(glob) <= MAX_GLOB_WILDCARDS;
|
|
80
|
+
}
|
|
81
|
+
export function globMatch(glob, path) {
|
|
82
|
+
if (typeof glob !== 'string' || typeof path !== 'string' || glob === '')
|
|
83
|
+
return false;
|
|
84
|
+
if (path.length > MAX_GLOB_PATH_LENGTH)
|
|
85
|
+
return false;
|
|
86
|
+
// REFUSE rather than compile: an unbounded-degree pattern is a denial of service, and a glob
|
|
87
|
+
// that never matches is the safe failure here (a promoted rule that reports nothing, not a hang).
|
|
88
|
+
if (!isSafeGlob(glob))
|
|
89
|
+
return false;
|
|
90
|
+
const g = normalizeGlob(glob);
|
|
91
|
+
let re = '';
|
|
92
|
+
for (let i = 0; i < g.length; i++) {
|
|
93
|
+
const c = g[i];
|
|
94
|
+
if (c === '*') {
|
|
95
|
+
if (g[i + 1] === '*') {
|
|
96
|
+
if (g[i + 2] === '/') {
|
|
97
|
+
re += '(?:.*/)?'; // `**/` spans zero or more directory segments
|
|
98
|
+
i += 2;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
re += '.*';
|
|
102
|
+
i += 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
re += '[^/]*';
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
re += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
return new RegExp(`^${re}$`).test(path);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Does this (template, params) fire on this change? ONE definition, used by BOTH the historical
|
|
121
|
+
* replay and `evaluateGuard`'s template checker — a second copy would let the promoter promise a
|
|
122
|
+
* rule the guard then enforces differently, silently.
|
|
123
|
+
*
|
|
124
|
+
* `undecidable` (not `fired:false`) when the evidence the template needs is absent: a
|
|
125
|
+
* `format-match` over a change whose contents were not fetched is NOT a clean change, and counting
|
|
126
|
+
* it as a non-firing would convert missing data into a LOSS (the INSUFFICIENT_DATA discipline).
|
|
127
|
+
*/
|
|
128
|
+
export function templateFires(template, params, change) {
|
|
129
|
+
const files = Array.isArray(change?.files) ? change.files.filter((f) => typeof f === 'string') : [];
|
|
130
|
+
if (template === 'pairing-check') {
|
|
131
|
+
const armed = files.filter((f) => globMatch(params.when, f));
|
|
132
|
+
if (armed.length === 0)
|
|
133
|
+
return { fired: false };
|
|
134
|
+
if (files.some((f) => globMatch(params.requires, f)))
|
|
135
|
+
return { fired: false };
|
|
136
|
+
return { fired: true, detail: `${armed.slice(0, 3).join(', ')} changed without any ${params.requires}` };
|
|
137
|
+
}
|
|
138
|
+
if (template === 'absence-check') {
|
|
139
|
+
const hit = files.filter((f) => globMatch(params.forbid, f));
|
|
140
|
+
return hit.length === 0 ? { fired: false } : { fired: true, detail: `${hit.slice(0, 3).join(', ')} matches the forbidden pattern ${params.forbid}` };
|
|
141
|
+
}
|
|
142
|
+
// format-match
|
|
143
|
+
const targets = files.filter((f) => globMatch(params.file, f));
|
|
144
|
+
if (targets.length === 0)
|
|
145
|
+
return { fired: false };
|
|
146
|
+
const contents = change.contents;
|
|
147
|
+
if (!contents || typeof contents !== 'object')
|
|
148
|
+
return { undecidable: `no content for ${targets.length} file(s) matching ${params.file}` };
|
|
149
|
+
const missing = [];
|
|
150
|
+
for (const t of targets) {
|
|
151
|
+
const text = Object.hasOwn(contents, t) ? contents[t] : undefined;
|
|
152
|
+
if (typeof text !== 'string')
|
|
153
|
+
return { undecidable: `no content for ${t}` };
|
|
154
|
+
if (!text.includes(String(params.mustMatch)))
|
|
155
|
+
missing.push(t);
|
|
156
|
+
}
|
|
157
|
+
return missing.length === 0 ? { fired: false } : { fired: true, detail: `${missing.slice(0, 3).join(', ')} does not contain ${JSON.stringify(params.mustMatch)}` };
|
|
158
|
+
}
|
|
159
|
+
export function isClassified(x) {
|
|
160
|
+
return Object.hasOwn(x, 'template');
|
|
161
|
+
}
|
|
162
|
+
/** Extensions a token must carry to count as an artifact reference. Closed list on purpose. */
|
|
163
|
+
const ARTIFACT_EXT = ['md', 'json', 'ts', 'tsx', 'js', 'mjs', 'cjs', 'yaml', 'yml', 'toml', 'lock', 'txt'];
|
|
164
|
+
const ARTIFACT_RE = new RegExp(`\\b[\\w.@/-]*[\\w@-]\\.(?:${ARTIFACT_EXT.join('|')})\\b`, 'g');
|
|
165
|
+
const PAIRING_RE = /\b(?:without|in the same (?:commit|change|diff|pr|merge request)|must also|requires? a[n]? [\w-]*\s*refresh|alongside)\b/i;
|
|
166
|
+
const ABSENCE_RE = /\b(?:never|must not|do not|don't|no longer)\b[^.]{0,80}?\b(?:commit|publish|ship|include|contain|add|check in)\b/i;
|
|
167
|
+
const FORMAT_RE = /\b(?:must (?:match|agree|equal|contain|carry)|in sync with|consistent with|agree with)\b/i;
|
|
168
|
+
/** Repo-STATE phrasing — recognised only so the refusal can name WHY (ADR-002). */
|
|
169
|
+
const PRESENCE_RE = /\b(?:every|each|all)\b[^.]{0,80}?\b(?:must (?:have|carry|ship with|contain|include)|needs? an?)\b/i;
|
|
170
|
+
/**
|
|
171
|
+
* The discriminator between a repo-STATE predicate and a CHANGE predicate that happen to share the
|
|
172
|
+
* phrase *"must contain"*. Codex QE MED-4: *"Every package must contain X in package.json"* is a
|
|
173
|
+
* state predicate over repo entities and slipped through as `format-match`; *"Every CHANGED
|
|
174
|
+
* package.json must contain X"* scopes over the change set and is genuinely per-commit decidable.
|
|
175
|
+
* The word that scopes it is the whole difference, so it is the whole test.
|
|
176
|
+
*/
|
|
177
|
+
const CHANGE_SCOPED_RE = /\b(?:changed|modified|touched|edited|updated|committed|staged)\b/i;
|
|
178
|
+
/** Backticked spans, in order — the highest-confidence token source. */
|
|
179
|
+
function backticked(text) {
|
|
180
|
+
const out = [];
|
|
181
|
+
const re = /`([^`\n]{1,120})`/g;
|
|
182
|
+
let m;
|
|
183
|
+
while ((m = re.exec(text)) !== null)
|
|
184
|
+
if (m[1] !== undefined)
|
|
185
|
+
out.push(m[1].trim());
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/** Artifact tokens in TEXT ORDER, deduped. A token with no `/` becomes a basename glob. */
|
|
189
|
+
export function artifactTokens(text) {
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const out = [];
|
|
192
|
+
for (const raw of text.match(ARTIFACT_RE) ?? []) {
|
|
193
|
+
const t = raw.replace(/^[./]+/, '');
|
|
194
|
+
if (t === '' || seen.has(t))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(t);
|
|
197
|
+
out.push(t);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
/** `README.md` → `**/README.md`; `packages/x/README.md` → itself. */
|
|
202
|
+
export function tokenToGlob(token) {
|
|
203
|
+
return token.includes('/') ? token : `**/${token}`;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Reduce a lesson to a (template, params) pair, or refuse WITH A REASON.
|
|
207
|
+
*
|
|
208
|
+
* Conservative by construction and asymmetric by design: a false negative costs a missed promotion
|
|
209
|
+
* (the lesson stays exactly where it already was); a false positive is caught downstream by the
|
|
210
|
+
* win-twice gate and the duplicate refusal, and even a survivor lands SOFT + advisory.
|
|
211
|
+
*/
|
|
212
|
+
export function classifyLesson(text) {
|
|
213
|
+
if (typeof text !== 'string' || text.trim() === '')
|
|
214
|
+
return { reason: 'not-promotable: empty lesson text' };
|
|
215
|
+
const tokens = artifactTokens(text);
|
|
216
|
+
// PAIRING first: it is the most specific shape and the corpus phrases it explicitly.
|
|
217
|
+
if (PAIRING_RE.test(text)) {
|
|
218
|
+
if (tokens.length < 2)
|
|
219
|
+
return { reason: `not-promotable: pairing-shaped but only ${tokens.length} artifact token(s) — cannot bind when/requires` };
|
|
220
|
+
if (tokens.length > 3)
|
|
221
|
+
return { reason: `not-promotable: pairing-shaped but ambiguous (${tokens.length} distinct artifact tokens; the classifier binds at most 2)` };
|
|
222
|
+
const when = tokens[0];
|
|
223
|
+
const requires = tokens[1];
|
|
224
|
+
return { template: 'pairing-check', params: { when: tokenToGlob(when), requires: tokenToGlob(requires) }, tokens: [when, requires] };
|
|
225
|
+
}
|
|
226
|
+
if (ABSENCE_RE.test(text)) {
|
|
227
|
+
if (tokens.length !== 1)
|
|
228
|
+
return { reason: `not-promotable: absence-shaped but ${tokens.length} artifact token(s) — absence-check binds exactly 1` };
|
|
229
|
+
return { template: 'absence-check', params: { forbid: tokenToGlob(tokens[0]) }, tokens: [tokens[0]] };
|
|
230
|
+
}
|
|
231
|
+
// PRESENCE IS TESTED BEFORE FORMAT (Codex QE MED-4). Both surface as "must contain", but only the
|
|
232
|
+
// change-scoped one is decidable per commit. A state predicate that reached `format-match` would be
|
|
233
|
+
// replayed against whatever files happened to change — an answer to a different question.
|
|
234
|
+
if (PRESENCE_RE.test(text) && !CHANGE_SCOPED_RE.test(text)) {
|
|
235
|
+
return {
|
|
236
|
+
reason: 'not-promotable: presence-shaped (a repo-STATE predicate over repo entities, not over a change). v1 has no shadow evaluator for state predicates — replaying one against today\'s tree returns the same answer every window and would MANUFACTURE two consecutive wins from one observation. Re-word it to scope over the CHANGE ("every CHANGED <file> must …") if that is what you mean (ADR-002)',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (FORMAT_RE.test(text)) {
|
|
240
|
+
const literals = backticked(text).filter((s) => !ARTIFACT_RE.test(s) && s.length >= 3);
|
|
241
|
+
ARTIFACT_RE.lastIndex = 0; // the /g regex above is stateful — reset or the next call skips matches
|
|
242
|
+
if (tokens.length !== 1)
|
|
243
|
+
return { reason: `not-promotable: format-shaped but ${tokens.length} artifact token(s) — format-match binds exactly 1` };
|
|
244
|
+
if (literals.length !== 1)
|
|
245
|
+
return { reason: `not-promotable: format-shaped but ${literals.length} backticked literal(s) — format-match needs exactly 1 to match against` };
|
|
246
|
+
return { template: 'format-match', params: { file: tokenToGlob(tokens[0]), mustMatch: literals[0] }, tokens: [tokens[0]] };
|
|
247
|
+
}
|
|
248
|
+
return { reason: 'not-promotable: no template matched (the lesson is semantic, not a deterministic change predicate)' };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* FNV-1a, 32-bit — a DISCRIMINATOR, not a security primitive, and labelled as one.
|
|
252
|
+
*
|
|
253
|
+
* It exists solely to keep two DIFFERENT rule bodies from claiming the same id after slug
|
|
254
|
+
* normalisation (`a.b.json` and `a-b.json` both slug to `a-b-json`). Nothing trusts it for
|
|
255
|
+
* integrity or authenticity; the key space is a few dozen self-generated rule bodies, so a
|
|
256
|
+
* non-cryptographic 32-bit mix is ample. Kept in-module because this file is deliberately pure with
|
|
257
|
+
* zero imports (NFR-1) — reaching for `node:crypto` here would buy nothing the threat model needs.
|
|
258
|
+
*/
|
|
259
|
+
export function fnv1a32(s) {
|
|
260
|
+
let h = 0x811c9dc5;
|
|
261
|
+
for (let i = 0; i < s.length; i++) {
|
|
262
|
+
h ^= s.charCodeAt(i);
|
|
263
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
264
|
+
}
|
|
265
|
+
return h.toString(16).padStart(8, '0');
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Stable rule id derived from the template + its bound params.
|
|
269
|
+
*
|
|
270
|
+
* The trailing hash is load-bearing (Codex QE MED-6): the slug lowercases and collapses every
|
|
271
|
+
* non-alphanumeric run, so `a.b.json` and `a-b.json` — two genuinely different rules — produced the
|
|
272
|
+
* SAME id and the second silently read as a duplicate of the first. The hash is taken over the
|
|
273
|
+
* template and the actual PARAMS (not the pre-normalisation tokens), so two rules collide only if
|
|
274
|
+
* they would enforce exactly the same thing.
|
|
275
|
+
*/
|
|
276
|
+
export function derivedRuleId(c) {
|
|
277
|
+
const slug = c.tokens
|
|
278
|
+
.map((t) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''))
|
|
279
|
+
.filter(Boolean)
|
|
280
|
+
.join('-');
|
|
281
|
+
const kind = c.template.replace('-check', '').replace('format-match', 'format');
|
|
282
|
+
const hash = fnv1a32(paramsKey(c.template, c.params)).slice(0, 6);
|
|
283
|
+
return `${`promoted-${kind}-${slug}`.slice(0, 72)}-${hash}`;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The character set a promoted rule id may use. Enforced wherever an id becomes part of a FILE PATH:
|
|
287
|
+
* an id is data that has round-tripped through `.dz/promotion-state.json`, and a path segment built
|
|
288
|
+
* from unvalidated data is an arbitrary-write primitive (Codex QE HIGH-1).
|
|
289
|
+
*/
|
|
290
|
+
export function isSafeRuleId(id) {
|
|
291
|
+
return typeof id === 'string' && id.length > 0 && id.length <= 100 && /^promoted-[a-z0-9][a-z0-9-]*$/.test(id);
|
|
292
|
+
}
|
|
293
|
+
/** The one place mini-ADR paths are defined (POSIX-relative, forward slashes). */
|
|
294
|
+
export const PROMOTIONS_REL_DIR = 'features/guard-promotion/promotions';
|
|
295
|
+
/**
|
|
296
|
+
* DERIVE a mini-ADR path from a validated id + an integer sequence — the only way a promotion
|
|
297
|
+
* document path is ever produced (Codex QE HIGH-1). Returns `null` when either input fails
|
|
298
|
+
* validation, so a caller that gets `null` writes nothing rather than falling back to a raw string.
|
|
299
|
+
* The character set (`isSafeRuleId`) admits no `/`, no `.`, and no `..`, so the result cannot escape
|
|
300
|
+
* {@link PROMOTIONS_REL_DIR}; callers still assert containment after resolving, because a derivation
|
|
301
|
+
* that is correct today is not a substitute for checking the thing you are about to write.
|
|
302
|
+
*/
|
|
303
|
+
export function promotionAdrRelPath(ruleId, seq) {
|
|
304
|
+
if (!isSafeRuleId(ruleId))
|
|
305
|
+
return null;
|
|
306
|
+
if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0 || seq > 100_000)
|
|
307
|
+
return null;
|
|
308
|
+
return `${PROMOTIONS_REL_DIR}/${String(seq).padStart(3, '0')}-${ruleId}.md`;
|
|
309
|
+
}
|
|
310
|
+
// ── Coverage: what the built-in rules ALREADY do (ADR-002) ──────────────────────────────────────
|
|
311
|
+
/**
|
|
312
|
+
* Template-equivalents of the built-in guard rules that have one. Rule ids are plain literals — this
|
|
313
|
+
* module must not import `guard.ts` (guard.ts imports THIS one).
|
|
314
|
+
*
|
|
315
|
+
* DELIBERATELY PARTIAL. `no-secrets` (content regexes), `readme-consistency` (numeric parity),
|
|
316
|
+
* `no-skill-drift` (byte comparison) and `store-bloat-cap` (a counter) have no template equivalent
|
|
317
|
+
* and are simply absent. Partiality errs in the SAFE direction only because over-refusing costs a
|
|
318
|
+
* missed promotion while under-refusing ships a duplicate rule — so when in doubt, add an entry.
|
|
319
|
+
*/
|
|
320
|
+
export const BUILTIN_COVERAGE = {
|
|
321
|
+
'readme-first': { template: 'pairing-check', params: { when: '**/package.json', requires: '**/README.md' } },
|
|
322
|
+
'lockfile-in-sync': { template: 'pairing-check', params: { when: '**/package.json', requires: '**/pnpm-lock.yaml' } },
|
|
323
|
+
};
|
|
324
|
+
/** Order-insensitive, whitespace-insensitive params key for equality. */
|
|
325
|
+
export function paramsKey(template, params) {
|
|
326
|
+
const p = params ?? {};
|
|
327
|
+
const parts = Object.keys(p)
|
|
328
|
+
.sort()
|
|
329
|
+
.map((k) => `${k}=${String(p[k]).trim()}`);
|
|
330
|
+
return `${template}|${parts.join('&')}`;
|
|
331
|
+
}
|
|
332
|
+
/** The id of the rule that already covers this candidate, or `null`. */
|
|
333
|
+
export function coveringRule(c, existing) {
|
|
334
|
+
const key = paramsKey(c.template, c.params);
|
|
335
|
+
for (const [id, cov] of Object.entries(BUILTIN_COVERAGE)) {
|
|
336
|
+
if (paramsKey(cov.template, cov.params) === key && existing.some((e) => e?.id === id))
|
|
337
|
+
return id;
|
|
338
|
+
}
|
|
339
|
+
for (const e of existing) {
|
|
340
|
+
if (!e || typeof e.id !== 'string')
|
|
341
|
+
continue;
|
|
342
|
+
if (e.template !== undefined && e.params !== undefined && paramsKey(e.template, e.params) === key)
|
|
343
|
+
return e.id;
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
// ── The win-twice gate (ADR-003) ────────────────────────────────────────────────────────────────
|
|
348
|
+
export const DEFAULT_WINDOW_DAYS = 7;
|
|
349
|
+
export const DEFAULT_PERIODS = 4;
|
|
350
|
+
/** Below this many changes a window carries no information — it is SKIPPED, never counted a loss. */
|
|
351
|
+
export const MIN_CHANGES_PER_PERIOD = 5;
|
|
352
|
+
export const WINS_TO_PROMOTE = 2;
|
|
353
|
+
/** Cap on `git show` fetches per run; over it, a format-match candidate is insufficient-data. */
|
|
354
|
+
export const MAX_CONTENT_FETCHES = 200;
|
|
355
|
+
const DAY_MS = 86_400_000;
|
|
356
|
+
/**
|
|
357
|
+
* Cut history into `periods` consecutive `windowDays` windows anchored at `nowMs`, walking BACKWARDS
|
|
358
|
+
* and returned oldest→newest. Wall-clock windows, NOT per-invocation and NOT per-commit-count: an
|
|
359
|
+
* operator's invocation frequency must never be an input to a safety gate (ADR-003 option A).
|
|
360
|
+
*/
|
|
361
|
+
export function buildPeriods(changes, nowMs, windowDays = DEFAULT_WINDOW_DAYS, periods = DEFAULT_PERIODS) {
|
|
362
|
+
const w = Number.isFinite(windowDays) && windowDays >= 1 ? Math.min(Math.floor(windowDays), 365) : DEFAULT_WINDOW_DAYS;
|
|
363
|
+
const n = Number.isFinite(periods) && periods >= 1 ? Math.min(Math.floor(periods), 52) : DEFAULT_PERIODS;
|
|
364
|
+
const now = Number.isFinite(nowMs) ? nowMs : 0;
|
|
365
|
+
const timed = (Array.isArray(changes) ? changes : [])
|
|
366
|
+
.map((c) => ({ c, ms: Date.parse(c?.ts ?? '') }))
|
|
367
|
+
.filter((x) => Number.isFinite(x.ms));
|
|
368
|
+
const out = [];
|
|
369
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
370
|
+
const end = now - i * w * DAY_MS;
|
|
371
|
+
const start = end - w * DAY_MS;
|
|
372
|
+
out.push({
|
|
373
|
+
start: new Date(start).toISOString(),
|
|
374
|
+
end: new Date(end).toISOString(),
|
|
375
|
+
changes: timed.filter((x) => x.ms > start && x.ms <= end).map((x) => x.c),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Replay the candidate over each period.
|
|
382
|
+
*
|
|
383
|
+
* A period below {@link MIN_CHANGES_PER_PERIOD} is SKIPPED — a one-commit week that happens not to
|
|
384
|
+
* touch package.json is NOT evidence the pairing rule is worthless, it is NO evidence, and absence
|
|
385
|
+
* of data must never be converted into a negative observation.
|
|
386
|
+
*/
|
|
387
|
+
export function evaluateCandidate(c, periods, minChanges = MIN_CHANGES_PER_PERIOD) {
|
|
388
|
+
const floor = Number.isFinite(minChanges) && minChanges >= 1 ? Math.floor(minChanges) : MIN_CHANGES_PER_PERIOD;
|
|
389
|
+
const results = [];
|
|
390
|
+
let wins = 0;
|
|
391
|
+
let evaluated = 0;
|
|
392
|
+
let totalFirings = 0;
|
|
393
|
+
let undecidable;
|
|
394
|
+
for (const p of Array.isArray(periods) ? periods : []) {
|
|
395
|
+
const changes = Array.isArray(p?.changes) ? p.changes : [];
|
|
396
|
+
if (changes.length < floor) {
|
|
397
|
+
results.push({ start: p.start, end: p.end, changes: changes.length, firings: 0, outcome: 'skipped' });
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
let firings = 0;
|
|
401
|
+
let evidence;
|
|
402
|
+
for (const ch of changes) {
|
|
403
|
+
const r = templateFires(c.template, c.params, ch);
|
|
404
|
+
if (Object.hasOwn(r, 'undecidable')) {
|
|
405
|
+
undecidable = undecidable ?? r.undecidable;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (r.fired) {
|
|
409
|
+
firings += 1;
|
|
410
|
+
evidence = evidence ?? `${ch.id}${r.detail ? `: ${r.detail}` : ''}`;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
evaluated += 1;
|
|
414
|
+
totalFirings += firings;
|
|
415
|
+
// A win increments; a LOSS RESETS TO ZERO (ADR-G008's rule, kept verbatim).
|
|
416
|
+
if (firings > 0)
|
|
417
|
+
wins += 1;
|
|
418
|
+
else
|
|
419
|
+
wins = 0;
|
|
420
|
+
results.push({ start: p.start, end: p.end, changes: changes.length, firings, outcome: firings > 0 ? 'win' : 'loss', ...(evidence !== undefined ? { evidence } : {}) });
|
|
421
|
+
}
|
|
422
|
+
return { periods: results, evaluatedPeriods: evaluated, wins, totalFirings, ...(undecidable !== undefined ? { undecidable } : {}) };
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* A promotion also needs this many WINDOW-LENGTHS of REAL elapsed time since the candidate was first
|
|
426
|
+
* recorded — a defence Codex QE (MED-7) showed the window logic alone does not provide.
|
|
427
|
+
*
|
|
428
|
+
* The threat is not an attacker; it is ACCIDENTAL SELF-GAMING. Commit timestamps are author-supplied
|
|
429
|
+
* (`GIT_COMMITTER_DATE`, a rebase, an import, a clock skew), so a repo whose history is minted in one
|
|
430
|
+
* afternoon can present two full "windows" instantly, and the gate that is supposed to mean *"this
|
|
431
|
+
* recurred over two separate stretches of work"* would mean nothing.
|
|
432
|
+
*
|
|
433
|
+
* THE HONEST SPLIT, stated so it is not mistaken for more than it is:
|
|
434
|
+
* • committer dates are trusted for firing ATTRIBUTION — which commit a violation belongs to;
|
|
435
|
+
* • the LOCAL clock, journalled in state, gates ELAPSED time — how long we have been watching.
|
|
436
|
+
* This is not cryptographic and does not resist a determined forger (state is a local JSON file you
|
|
437
|
+
* can edit). It resists the realistic failure: history that only LOOKS like it spans two windows.
|
|
438
|
+
*/
|
|
439
|
+
export const ELAPSED_WINDOWS_REQUIRED = 2;
|
|
440
|
+
export function promotedRuleObject(c, ruleId, lessonId) {
|
|
441
|
+
const what = c.template === 'pairing-check'
|
|
442
|
+
? `a change touching ${c.params.when} must also touch ${c.params.requires}`
|
|
443
|
+
: c.template === 'absence-check'
|
|
444
|
+
? `no change may touch ${c.params.forbid}`
|
|
445
|
+
: `every changed ${c.params.file} must contain ${JSON.stringify(c.params.mustMatch)}`;
|
|
446
|
+
return {
|
|
447
|
+
id: ruleId,
|
|
448
|
+
severity: 'soft',
|
|
449
|
+
ops: ['publish'],
|
|
450
|
+
enabled: true,
|
|
451
|
+
template: c.template,
|
|
452
|
+
params: c.params,
|
|
453
|
+
description: `${what} — promoted from lesson ${lessonId} after ${WINS_TO_PROMOTE} consecutive shadow wins (dz guard promote)`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Rank every lesson and decide. Deterministic: the sort is (score desc, ruleId asc, lessonId asc), so
|
|
458
|
+
* ties never reorder between runs.
|
|
459
|
+
*/
|
|
460
|
+
export function assembleCandidates(facts) {
|
|
461
|
+
const lessons = Array.isArray(facts?.lessons) ? facts.lessons : [];
|
|
462
|
+
const existing = Array.isArray(facts?.existingRules) ? facts.existingRules : [];
|
|
463
|
+
const nowMs = Date.parse(facts?.nowTs ?? '');
|
|
464
|
+
const windowDays = Number.isFinite(facts?.windowDays) ? facts.windowDays : DEFAULT_WINDOW_DAYS;
|
|
465
|
+
const periodCount = Number.isFinite(facts?.periods) ? facts.periods : DEFAULT_PERIODS;
|
|
466
|
+
const periods = buildPeriods(facts?.changes ?? [], Number.isFinite(nowMs) ? nowMs : 0, windowDays, periodCount);
|
|
467
|
+
// The REAL-elapsed requirement (MED-7), derived from the same window length the replay uses.
|
|
468
|
+
const windowMs = (Number.isFinite(windowDays) && windowDays >= 1 ? Math.min(Math.floor(windowDays), 365) : DEFAULT_WINDOW_DAYS) * DAY_MS;
|
|
469
|
+
const elapsedRequiredMs = ELAPSED_WINDOWS_REQUIRED * windowMs;
|
|
470
|
+
const firstSeenMap = facts?.firstSeen && typeof facts.firstSeen === 'object' ? facts.firstSeen : {};
|
|
471
|
+
const out = [];
|
|
472
|
+
let quarantinedSkipped = 0;
|
|
473
|
+
for (const l of lessons) {
|
|
474
|
+
if (!l || typeof l.dzId !== 'string')
|
|
475
|
+
continue;
|
|
476
|
+
const uses = Number.isFinite(l.uses) && l.uses >= 0 ? Math.floor(l.uses) : 0;
|
|
477
|
+
const cost = 1 + uses;
|
|
478
|
+
const base = { lessonId: l.dzId, lessonText: typeof l.text === 'string' ? l.text : '', cost, ruleId: null, template: null, params: null, score: 0, firings: 0, wins: 0, evaluatedPeriods: 0, periods: [], proposedRule: null, firstSeenTs: null, elapsedMs: 0, elapsedRequiredMs };
|
|
479
|
+
if (l.quarantined === true)
|
|
480
|
+
quarantinedSkipped += 1;
|
|
481
|
+
// (b) CHECKABILITY runs FIRST — not because it outranks trust, but because a refusal that names
|
|
482
|
+
// WHAT the lesson would become is a roadmap, and one that just says "quarantined" is a
|
|
483
|
+
// shrug. The trust gate below still decides the verdict; classification only informs it.
|
|
484
|
+
const cls = classifyLesson(l.text);
|
|
485
|
+
if (!isClassified(cls)) {
|
|
486
|
+
// For an unclassifiable lesson, quarantine is moot — the deeper fact is that no deterministic
|
|
487
|
+
// check can express it, and that stays true however reinforced it becomes.
|
|
488
|
+
out.push({ ...base, verdict: 'not-promotable', reason: cls.reason });
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const ruleId = derivedRuleId(cls);
|
|
492
|
+
const covering = coveringRule(cls, existing);
|
|
493
|
+
// LOCAL-clock first observation. Absent (or unparseable) ⇒ this run IS the first observation, so
|
|
494
|
+
// elapsed is 0 and nothing can promote — the clock starts when the candidate is first RECORDED,
|
|
495
|
+
// which `--dry-run` deliberately never does.
|
|
496
|
+
const firstSeenRaw = Object.hasOwn(firstSeenMap, ruleId) ? firstSeenMap[ruleId] : undefined;
|
|
497
|
+
const firstSeenMs = typeof firstSeenRaw === 'string' ? Date.parse(firstSeenRaw) : Number.NaN;
|
|
498
|
+
const firstSeenTs = Number.isFinite(firstSeenMs) ? firstSeenRaw : null;
|
|
499
|
+
// A FUTURE firstSeen (clock skew, hand-edited state) must not mint elapsed time: clamp at 0.
|
|
500
|
+
const elapsedMs = Number.isFinite(firstSeenMs) && Number.isFinite(nowMs) ? Math.max(0, nowMs - firstSeenMs) : 0;
|
|
501
|
+
const shaped = { ...base, ruleId, template: cls.template, params: cls.params, firstSeenTs, elapsedMs };
|
|
502
|
+
// (a) TRUST — a fresh lesson is a hypothesis (lesson-quarantine ADR). Enforcement is the LAST
|
|
503
|
+
// thing an unproven hypothesis should earn. This gate BINDS, whatever the classification says.
|
|
504
|
+
if (l.quarantined === true) {
|
|
505
|
+
out.push({
|
|
506
|
+
...shaped,
|
|
507
|
+
verdict: 'not-promotable',
|
|
508
|
+
reason: `not-promotable: quarantined (an unproven hypothesis must not become an enforced rule) — it WOULD classify as ${cls.template}${covering !== null ? `, and is already covered by '${covering}'` : ''}; confirm it with \`dz teach --reinforce\` to make it eligible`,
|
|
509
|
+
});
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (covering !== null) {
|
|
513
|
+
out.push({ ...shaped, verdict: 'duplicate', reason: `duplicate: already covered by the existing rule '${covering}'` });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const ev = evaluateCandidate(cls, periods);
|
|
517
|
+
const score = ev.totalFirings * cost;
|
|
518
|
+
const common = { ...shaped, score, firings: ev.totalFirings, wins: ev.wins, evaluatedPeriods: ev.evaluatedPeriods, periods: ev.periods };
|
|
519
|
+
if (ev.undecidable !== undefined && ev.totalFirings === 0) {
|
|
520
|
+
out.push({ ...common, verdict: 'insufficient-data', reason: `insufficient-data: ${ev.undecidable}` });
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (ev.evaluatedPeriods < 2) {
|
|
524
|
+
out.push({ ...common, verdict: 'insufficient-data', reason: `insufficient-data: only ${ev.evaluatedPeriods} period(s) had >= ${MIN_CHANGES_PER_PERIOD} changes — WAITING (thin evidence never promotes and never rejects)` });
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (ev.wins >= WINS_TO_PROMOTE) {
|
|
528
|
+
// The SECOND clock (MED-7). Two "windows" of committer dates can be minted in one afternoon;
|
|
529
|
+
// real elapsed time since the candidate was first RECORDED cannot. Both must pass.
|
|
530
|
+
if (elapsedMs < elapsedRequiredMs) {
|
|
531
|
+
const days = (ms) => (ms / DAY_MS).toFixed(1);
|
|
532
|
+
out.push({
|
|
533
|
+
...common,
|
|
534
|
+
verdict: 'wait',
|
|
535
|
+
reason: `wait: ${ev.wins} consecutive shadow win(s), but only ${days(elapsedMs)}d of the ${days(elapsedRequiredMs)}d REAL elapsed time required since first observation` +
|
|
536
|
+
(firstSeenTs === null
|
|
537
|
+
? ' — this run is the first observation; commit dates are author-supplied, so elapsed time is measured by the local clock recorded in .dz/promotion-state.json (a --dry-run never starts that clock)'
|
|
538
|
+
: ` (first seen ${firstSeenTs})`),
|
|
539
|
+
});
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
out.push({ ...common, verdict: 'promote', reason: `promote: ${ev.wins} consecutive shadow win(s) over ${ev.evaluatedPeriods} evaluated period(s), ${ev.totalFirings} real firing(s), and ${(elapsedMs / DAY_MS).toFixed(1)}d of real elapsed time since first observation`, proposedRule: promotedRuleObject(cls, ruleId, l.dzId) });
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
out.push({ ...common, verdict: 'wait', reason: `wait: ${ev.wins}/${WINS_TO_PROMOTE} consecutive shadow win(s) over ${ev.evaluatedPeriods} evaluated period(s)` });
|
|
546
|
+
}
|
|
547
|
+
out.sort((a, b) => b.score - a.score || (a.ruleId ?? '').localeCompare(b.ruleId ?? '') || a.lessonId.localeCompare(b.lessonId));
|
|
548
|
+
const n = (v) => out.filter((c) => c.verdict === v).length;
|
|
549
|
+
const verdict = `${out.length} lesson(s) · promote ${n('promote')} · wait ${n('wait')} · insufficient-data ${n('insufficient-data')} · duplicate ${n('duplicate')} · not-promotable ${n('not-promotable')}`;
|
|
550
|
+
return {
|
|
551
|
+
candidates: out,
|
|
552
|
+
totalLessons: lessons.length,
|
|
553
|
+
quarantinedSkipped,
|
|
554
|
+
windowDays: periods.length > 0 ? windowDays : DEFAULT_WINDOW_DAYS,
|
|
555
|
+
periodCount: periods.length,
|
|
556
|
+
totalChanges: Array.isArray(facts?.changes) ? facts.changes.length : 0,
|
|
557
|
+
verdict,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
export const EMPTY_PROMOTION_STATE = { version: 1, nextAdrSeq: 1, entries: {} };
|
|
561
|
+
/**
|
|
562
|
+
* Keys that must never become an entry name. `Object.hasOwn` stops a polluted JSON from being READ
|
|
563
|
+
* through the prototype, but it does not stop `entries[key] = …` from WRITING through it: `JSON.parse`
|
|
564
|
+
* gives `__proto__` as an own property, and a plain assignment with that key sets the object's
|
|
565
|
+
* prototype instead of adding a member — so `state.entries.ruleId` then resolves to the attacker's
|
|
566
|
+
* value. (Found by this feature's own hostile-input test, not by review.)
|
|
567
|
+
*/
|
|
568
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
569
|
+
/**
|
|
570
|
+
* Read state defensively. `Object.hasOwn` (never `in`) so a prototype-polluted JSON cannot conjure an
|
|
571
|
+
* entry; `Number.isInteger` on every counter because `1e400` parses to `Infinity`, passes `> 0`, and
|
|
572
|
+
* this repo has already been bitten by exactly that twice (storeCap, auto-cost).
|
|
573
|
+
*/
|
|
574
|
+
export function normalizePromotionState(raw) {
|
|
575
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
576
|
+
return EMPTY_PROMOTION_STATE;
|
|
577
|
+
const o = raw;
|
|
578
|
+
if (!Object.hasOwn(o, 'version') || o['version'] !== 1)
|
|
579
|
+
return EMPTY_PROMOTION_STATE;
|
|
580
|
+
const seqRaw = o['nextAdrSeq'];
|
|
581
|
+
const nextAdrSeq = typeof seqRaw === 'number' && Number.isInteger(seqRaw) && seqRaw >= 1 && seqRaw <= 100_000 ? seqRaw : 1;
|
|
582
|
+
const entries = {};
|
|
583
|
+
const rawEntries = o['entries'];
|
|
584
|
+
if (rawEntries && typeof rawEntries === 'object' && !Array.isArray(rawEntries)) {
|
|
585
|
+
for (const key of Object.keys(rawEntries)) {
|
|
586
|
+
if (!Object.hasOwn(rawEntries, key) || UNSAFE_KEYS.has(key))
|
|
587
|
+
continue;
|
|
588
|
+
const e = rawEntries[key];
|
|
589
|
+
if (!e || typeof e !== 'object' || Array.isArray(e))
|
|
590
|
+
continue;
|
|
591
|
+
const r = e;
|
|
592
|
+
const str = (k) => (typeof r[k] === 'string' ? r[k] : undefined);
|
|
593
|
+
const int = (k) => (typeof r[k] === 'number' && Number.isInteger(r[k]) && r[k] >= 0 ? r[k] : 0);
|
|
594
|
+
const ruleId = str('ruleId');
|
|
595
|
+
const lessonId = str('lessonId');
|
|
596
|
+
if (ruleId === undefined || lessonId === undefined)
|
|
597
|
+
continue;
|
|
598
|
+
// The KEY is used to derive a file path, so it must satisfy the id whitelist — and it must
|
|
599
|
+
// agree with the entry's own ruleId, so a well-named key cannot smuggle a hostile body.
|
|
600
|
+
if (!isSafeRuleId(key) || key !== ruleId)
|
|
601
|
+
continue;
|
|
602
|
+
const seqRawE = r['adrSeq'];
|
|
603
|
+
const adrSeq = typeof seqRawE === 'number' && Number.isInteger(seqRawE) && seqRawE >= 0 && seqRawE <= 100_000 ? seqRawE : undefined;
|
|
604
|
+
entries[key] = {
|
|
605
|
+
ruleId,
|
|
606
|
+
lessonId,
|
|
607
|
+
// A malformed-but-nonempty firstSeenTs would WEDGE the elapsed clock forever (now − NaN is
|
|
608
|
+
// never ≥ anything) — an unparseable timestamp RESTARTS the clock instead (Codex re-QE LOW).
|
|
609
|
+
firstSeenTs: (() => { const v = str('firstSeenTs') ?? ''; return v !== '' && Number.isFinite(Date.parse(v)) ? v : ''; })(),
|
|
610
|
+
lastRunTs: str('lastRunTs') ?? '',
|
|
611
|
+
wins: int('wins'),
|
|
612
|
+
evaluatedPeriods: int('evaluatedPeriods'),
|
|
613
|
+
verdict: ['promote', 'wait', 'insufficient-data', 'duplicate', 'not-promotable'].includes(str('verdict')) ? str('verdict') : 'wait',
|
|
614
|
+
...(adrSeq !== undefined ? { adrSeq } : {}),
|
|
615
|
+
...(str('appliedTs') !== undefined ? { appliedTs: str('appliedTs') } : {}),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return { version: 1, nextAdrSeq, entries };
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Fold a report into the state journal.
|
|
623
|
+
*
|
|
624
|
+
* THE ANTI-GAMING PROPERTY (SP-3): `wins` is OVERWRITTEN with the freshly recomputed value — it is
|
|
625
|
+
* never `prev.wins + …`. The state is a JOURNAL, not the source of truth, so running the promoter
|
|
626
|
+
* ten times over unchanged history leaves the counter exactly where one run leaves it. (Recalled
|
|
627
|
+
* lesson: "a learning loop's write path can promote by EXPOSURE without anyone noticing.")
|
|
628
|
+
*/
|
|
629
|
+
export function nextPromotionState(prev, report, nowTs, adrSeqs = {}, newlyAllocated) {
|
|
630
|
+
const base = normalizePromotionState(prev);
|
|
631
|
+
const entries = { ...base.entries };
|
|
632
|
+
for (const c of report.candidates) {
|
|
633
|
+
// Unclassifiable lessons get no journal entry (they have no rule identity); an id that fails the
|
|
634
|
+
// whitelist gets none either, because the key is later turned into a file path.
|
|
635
|
+
if (c.ruleId === null || UNSAFE_KEYS.has(c.ruleId) || !isSafeRuleId(c.ruleId))
|
|
636
|
+
continue;
|
|
637
|
+
const old = Object.hasOwn(entries, c.ruleId) ? entries[c.ruleId] : undefined;
|
|
638
|
+
entries[c.ruleId] = {
|
|
639
|
+
ruleId: c.ruleId,
|
|
640
|
+
lessonId: c.lessonId,
|
|
641
|
+
firstSeenTs: old?.firstSeenTs && old.firstSeenTs !== '' ? old.firstSeenTs : nowTs,
|
|
642
|
+
lastRunTs: nowTs,
|
|
643
|
+
wins: c.wins, // OVERWRITE, never accumulate — SP-3
|
|
644
|
+
evaluatedPeriods: c.evaluatedPeriods,
|
|
645
|
+
verdict: c.verdict,
|
|
646
|
+
...(Object.hasOwn(adrSeqs, c.ruleId) ? { adrSeq: adrSeqs[c.ruleId] } : old?.adrSeq !== undefined ? { adrSeq: old.adrSeq } : {}),
|
|
647
|
+
...(old?.appliedTs !== undefined ? { appliedTs: old.appliedTs } : {}),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
// Only NEWLY allocated documents advance the sequence — a re-refused candidate rewrites its own
|
|
651
|
+
// file, so counting every path would leave permanent gaps in the numbering.
|
|
652
|
+
const seq = Number.isInteger(newlyAllocated) && newlyAllocated >= 0 ? newlyAllocated : Object.keys(adrSeqs).length;
|
|
653
|
+
return { version: 1, nextAdrSeq: Math.min(100_000, base.nextAdrSeq + seq), entries };
|
|
654
|
+
}
|
|
655
|
+
// ── Rendering ───────────────────────────────────────────────────────────────────────────────────
|
|
656
|
+
const GLYPH = {
|
|
657
|
+
promote: '★',
|
|
658
|
+
wait: '·',
|
|
659
|
+
'insufficient-data': '?',
|
|
660
|
+
duplicate: '=',
|
|
661
|
+
'not-promotable': '✗',
|
|
662
|
+
};
|
|
663
|
+
export function renderPromotionReport(r, limit = 15) {
|
|
664
|
+
const out = [];
|
|
665
|
+
out.push('dz guard promote — lesson → guard-rule promotion (two consecutive shadow wins required)');
|
|
666
|
+
out.push('');
|
|
667
|
+
out.push(` corpus: ${r.totalLessons} lesson(s) · ${r.quarantinedSkipped} quarantined · ${r.totalChanges} change(s) over ${r.periodCount} × ${r.windowDays}d window(s)`);
|
|
668
|
+
out.push('');
|
|
669
|
+
// A lesson that REDUCED to a template is shown even when refused — that is the interesting half of
|
|
670
|
+
// the report. Only the unclassifiable ones collapse into the histogram below.
|
|
671
|
+
const ranked = r.candidates.filter((c) => c.ruleId !== null);
|
|
672
|
+
if (ranked.length === 0) {
|
|
673
|
+
out.push(' RANKED CANDIDATES: none — no lesson in the store reduces to a v1 rule template');
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
out.push(' RANKED CANDIDATES (score = firings × cost, cost = 1 + lesson uses — cost is a PROXY, not a token figure):');
|
|
677
|
+
for (const c of ranked.slice(0, limit)) {
|
|
678
|
+
out.push(` ${GLYPH[c.verdict]} [${String(c.score).padStart(4)}] ${c.ruleId ?? '(unclassified)'} ${c.verdict.toUpperCase()}`);
|
|
679
|
+
out.push(` ${c.reason}`);
|
|
680
|
+
if (c.periods.length > 0) {
|
|
681
|
+
out.push(` periods (oldest→newest): ${c.periods.map((p) => `${p.outcome === 'win' ? 'W' : p.outcome === 'loss' ? 'L' : '–'}${p.firings}/${p.changes}`).join(' ')}`);
|
|
682
|
+
const ev = c.periods.find((p) => p.evidence !== undefined);
|
|
683
|
+
if (ev?.evidence !== undefined)
|
|
684
|
+
out.push(` evidence: ${ev.evidence}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (ranked.length > limit)
|
|
688
|
+
out.push(` … ${ranked.length - limit} more`);
|
|
689
|
+
}
|
|
690
|
+
const refused = r.candidates.filter((c) => c.verdict === 'not-promotable' && c.ruleId === null);
|
|
691
|
+
if (refused.length > 0) {
|
|
692
|
+
out.push('');
|
|
693
|
+
out.push(` NOT PROMOTABLE — no template matched (${refused.length}), by reason:`);
|
|
694
|
+
const byReason = new Map();
|
|
695
|
+
for (const c of refused) {
|
|
696
|
+
const short = c.reason.replace(/^not-promotable: /, '').split(' —')[0].split(' (the classifier')[0];
|
|
697
|
+
byReason.set(short, (byReason.get(short) ?? 0) + 1);
|
|
698
|
+
}
|
|
699
|
+
for (const [reason, n] of [...byReason.entries()].sort((a, b) => b[1] - a[1]))
|
|
700
|
+
out.push(` ${String(n).padStart(4)} × ${reason}`);
|
|
701
|
+
}
|
|
702
|
+
out.push('');
|
|
703
|
+
out.push(` VERDICT: ${r.verdict}`);
|
|
704
|
+
return out.join('\n');
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* The mini-ADR for one decision. Written for PROMOTIONS and REJECTIONS alike (ADR-G008 requires
|
|
708
|
+
* both) — a refusal is a decision about the harness's own capability, and it is what turns the
|
|
709
|
+
* "not promotable" list into a roadmap instead of a shrug. `wait` / `insufficient-data` get NO
|
|
710
|
+
* document: they are not decisions yet, and one per run would bury the real ones.
|
|
711
|
+
*/
|
|
712
|
+
export function renderPromotionAdr(c, seq, nowTs) {
|
|
713
|
+
const decision = c.verdict === 'promote' ? 'PROMOTED (proposal)' : 'REFUSED';
|
|
714
|
+
const out = [];
|
|
715
|
+
out.push(`# ${String(seq).padStart(3, '0')} — ${c.ruleId ?? c.lessonId}`);
|
|
716
|
+
out.push('');
|
|
717
|
+
out.push(`**Decision:** ${decision}`);
|
|
718
|
+
out.push(`**Date:** ${nowTs}`);
|
|
719
|
+
out.push(`**Lesson:** \`${c.lessonId}\``);
|
|
720
|
+
out.push('');
|
|
721
|
+
out.push('## Lesson');
|
|
722
|
+
out.push('');
|
|
723
|
+
out.push('> ' + c.lessonText.replace(/\n/g, '\n> '));
|
|
724
|
+
out.push('');
|
|
725
|
+
out.push('## Classification');
|
|
726
|
+
out.push('');
|
|
727
|
+
out.push(`- template: \`${c.template ?? '(none)'}\``);
|
|
728
|
+
out.push(`- params: \`${JSON.stringify(c.params ?? {})}\``);
|
|
729
|
+
out.push('');
|
|
730
|
+
out.push('## Evidence');
|
|
731
|
+
out.push('');
|
|
732
|
+
out.push(`- score: **${c.score}** = ${c.firings} firing(s) × cost ${c.cost} (cost = 1 + lesson uses — a named PROXY, not a token/dollar figure)`);
|
|
733
|
+
out.push(`- consecutive shadow wins: **${c.wins}** / ${WINS_TO_PROMOTE} required`);
|
|
734
|
+
out.push(`- evaluated periods: ${c.evaluatedPeriods} (a period with < ${MIN_CHANGES_PER_PERIOD} changes is SKIPPED, never counted a loss)`);
|
|
735
|
+
if (c.periods.length > 0) {
|
|
736
|
+
out.push('');
|
|
737
|
+
out.push('| window start | window end | changes | firings | outcome | evidence |');
|
|
738
|
+
out.push('|---|---|---|---|---|---|');
|
|
739
|
+
for (const p of c.periods)
|
|
740
|
+
out.push(`| ${p.start} | ${p.end} | ${p.changes} | ${p.firings} | ${p.outcome} | ${p.evidence ?? '—'} |`);
|
|
741
|
+
}
|
|
742
|
+
out.push('');
|
|
743
|
+
out.push('## Reason');
|
|
744
|
+
out.push('');
|
|
745
|
+
out.push(c.reason);
|
|
746
|
+
out.push('');
|
|
747
|
+
if (c.proposedRule !== null) {
|
|
748
|
+
out.push('## The rule `--apply` would write into `.dz/guard.json`');
|
|
749
|
+
out.push('');
|
|
750
|
+
out.push('```json');
|
|
751
|
+
out.push(JSON.stringify(c.proposedRule, null, 2));
|
|
752
|
+
out.push('```');
|
|
753
|
+
out.push('');
|
|
754
|
+
out.push('Severity is `soft` and cannot be raised: `resolveRules` forces SOFT for every');
|
|
755
|
+
out.push('template-backed rule, so a hand-edited `"severity": "hard"` in the config is ignored');
|
|
756
|
+
out.push('(ADR-004 / the `lockfile-in-sync` precedent).');
|
|
757
|
+
}
|
|
758
|
+
return out.join('\n') + '\n';
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=guard-promotion.js.map
|