switchroom 0.21.16 → 0.21.18
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/agent-scheduler/index.js +5 -0
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/notion-write-pretool.mjs +5 -0
- package/dist/cli/switchroom.js +1492 -1136
- package/dist/host-control/main.js +6 -1
- package/dist/vault/approvals/kernel-server.js +5 -0
- package/dist/vault/broker/server.js +5 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +11 -0
- package/telegram-plugin/dist/gateway/gateway.js +9 -4
- package/telegram-plugin/scripts/bun-test-ci.sh +6 -0
- package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
- package/telegram-plugin/uat/flip/allowlist.ts +349 -0
- package/telegram-plugin/uat/flip/gate.test.ts +153 -0
- package/telegram-plugin/uat/flip/gate.ts +232 -0
- package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
- package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
- package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
- package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
- package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
- package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
- package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
- package/telegram-plugin/uat/flip/recall-log.ts +178 -0
- package/telegram-plugin/uat/flip/report.ts +95 -0
- package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
- package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
- package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
- package/telegram-plugin/uat/runners/scorer.ts +1 -1
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +29 -0
- package/vendor/hindsight-memory/scripts/orientation.py +195 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +10 -0
- package/vendor/hindsight-memory/scripts/recall.py +144 -11
- package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
- package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-1 directive⇄rules EQUIVALENCE check — the deterministic (no-model,
|
|
3
|
+
* no-network) half of the Memory v2 M3 directive-flip UAT gate.
|
|
4
|
+
*
|
|
5
|
+
* Before an agent is flipped (`memory.inject_directives: false`), its
|
|
6
|
+
* always-on directive residue moves into the CLAUDE.md rules block. This
|
|
7
|
+
* module proves — by construction, with no model in the loop — that the
|
|
8
|
+
* migrated rules still carry every guardrail the directives did: nothing
|
|
9
|
+
* dropped, nothing silently truncated, nothing invented.
|
|
10
|
+
*
|
|
11
|
+
* It is PURE and UNIT-TESTABLE:
|
|
12
|
+
* - the injected side is the directive objects the recall hook actually
|
|
13
|
+
* cached (`directives_cache.<agent>.json`, schema {id,name,content,
|
|
14
|
+
* priority}); the caller cross-checks that cache against the live bank
|
|
15
|
+
* before handing it here (same admin resolution as `resolveDirectiveAdmin`
|
|
16
|
+
* in `src/cli/memory-directive.ts`).
|
|
17
|
+
* - the rules side is parsed with {@link parseRulesBlock} from
|
|
18
|
+
* `src/memory/rules-block.ts` — IMPORTED, never re-implemented, so the
|
|
19
|
+
* parser this check trusts is byte-for-byte the one the store writes.
|
|
20
|
+
* - the residue filter reuses {@link RESIDUE_CATEGORIES} from
|
|
21
|
+
* `src/memory/directive-residue.ts`, so "which directives must have a
|
|
22
|
+
* rule" is the SAME set the byte-budget harness measures.
|
|
23
|
+
*
|
|
24
|
+
* The equivalence contract (all four must hold for PASS):
|
|
25
|
+
* (a) every ACTIVE residue directive (categories rules-block +
|
|
26
|
+
* reflect-directive) appears in `mapping`, mapped to a PRESENT rule id
|
|
27
|
+
* or an explicit `retired:<reason>`. Unmapped, or mapped to an absent
|
|
28
|
+
* rule / an empty retirement reason ⇒ `missing_from_rules`.
|
|
29
|
+
* (b) for each directive→rule pair, the normalized rule text preserves every
|
|
30
|
+
* GUARDRAIL the directive carries — its polarity/scope modals (matched by
|
|
31
|
+
* synonym class, so "don't"≡"NEVER" and "only"≡"sole"), its load-bearing
|
|
32
|
+
* facts (ALL-CAPS names, case/instrument codes), and any required-verbatim
|
|
33
|
+
* quoted line — and is not truncated ⇒ `truncated_or_drifted`. ILLUSTRATIVE
|
|
34
|
+
* tokens (sample toasts/commands the directive quoted as examples, incidental
|
|
35
|
+
* prose proper nouns) are NOT demanded; requiring them verbatim in a ~400B
|
|
36
|
+
* rule condensed from a ~10KB directive is a condensation artifact, not a
|
|
37
|
+
* dropped guardrail. See the tokenizer section for the exact class split.
|
|
38
|
+
* (c) the rendered rules block is ≤ 6144 bytes AND the sentinel's rule count
|
|
39
|
+
* equals the actual rule count.
|
|
40
|
+
* (d) reverse: no rule lacks a mapping source ⇒ `unsourced_rules`.
|
|
41
|
+
*
|
|
42
|
+
* Empty all three lists AND (c) holding ⇒ PASS.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
parseRulesBlock,
|
|
47
|
+
renderRulesBlock,
|
|
48
|
+
RULES_BLOCK_BUDGET_BYTES,
|
|
49
|
+
type Rule,
|
|
50
|
+
type ParsedRulesBlock,
|
|
51
|
+
} from "../../../src/memory/rules-block.js";
|
|
52
|
+
import { RESIDUE_CATEGORIES } from "../../../src/memory/directive-residue.js";
|
|
53
|
+
|
|
54
|
+
/** The injected side: one row of `directives_cache.<agent>.json`. `category`
|
|
55
|
+
* / `isActive` are optional — when the caller has classified the directive
|
|
56
|
+
* (via `buildDirectiveTriageRows`) it passes them so this module can filter
|
|
57
|
+
* to the residue set itself; when absent, the directive is treated as an
|
|
58
|
+
* active residue directive already (caller pre-filtered). */
|
|
59
|
+
export interface FlipDirective {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
content: string;
|
|
63
|
+
priority: number;
|
|
64
|
+
/** One of the E-45 triage categories, when known. */
|
|
65
|
+
category?: string;
|
|
66
|
+
/** Defaults to true when omitted. */
|
|
67
|
+
isActive?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `mapping[directiveId]` is either a rule id (e.g. `R-01`) that must be
|
|
72
|
+
* present in the parsed block, or the literal `retired:<reason>` marking a
|
|
73
|
+
* directive deliberately dropped (with a non-empty reason). A directive id
|
|
74
|
+
* absent from this map is an unmapped guardrail ⇒ FAIL.
|
|
75
|
+
*/
|
|
76
|
+
export type DirectiveRuleMapping = Record<string, string>;
|
|
77
|
+
|
|
78
|
+
const RETIRED_PREFIX = "retired:";
|
|
79
|
+
|
|
80
|
+
export interface MissingEntry {
|
|
81
|
+
id: string;
|
|
82
|
+
name: string;
|
|
83
|
+
/** Why it counts as missing: `unmapped`, `absent-rule`, or `empty-retire-reason`. */
|
|
84
|
+
reason: "unmapped" | "absent-rule" | "empty-retire-reason";
|
|
85
|
+
/** The mapping target, when there was one. */
|
|
86
|
+
mappedTo?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface DriftEntry {
|
|
90
|
+
id: string;
|
|
91
|
+
name: string;
|
|
92
|
+
ruleId: string;
|
|
93
|
+
/** Directive keywords not found in the rule text. */
|
|
94
|
+
missingKeywords: string[];
|
|
95
|
+
/** True when the rule text looks truncated (ellipsis or strict prefix). */
|
|
96
|
+
truncated: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface UnsourcedRule {
|
|
100
|
+
id: string;
|
|
101
|
+
text: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface EquivalenceReport {
|
|
105
|
+
pass: boolean;
|
|
106
|
+
missing_from_rules: MissingEntry[];
|
|
107
|
+
truncated_or_drifted: DriftEntry[];
|
|
108
|
+
unsourced_rules: UnsourcedRule[];
|
|
109
|
+
/** Rendered bytes of the rules block (renderRulesBlock over the parsed set). */
|
|
110
|
+
renderedBytes: number;
|
|
111
|
+
budgetBytes: number;
|
|
112
|
+
withinBudget: boolean;
|
|
113
|
+
/** Sentinel's declared rule count (null when no sentinel line was parsed). */
|
|
114
|
+
sentinelCount: number | null;
|
|
115
|
+
ruleCount: number;
|
|
116
|
+
sentinelMatchesCount: boolean;
|
|
117
|
+
/** How many directives formed the residue obligation set. */
|
|
118
|
+
residueDirectiveCount: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Calibrated tokenizer — GUARDRAIL vs ILLUSTRATIVE keyword classes
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
//
|
|
125
|
+
// M2→M3 triage condenses a ~10KB verbose directive into a ~400B rule. The old
|
|
126
|
+
// tokenizer demanded every quoted phrase / proper noun / exact modal token
|
|
127
|
+
// survive verbatim, which false-flagged ~100% of valid condensed drafts (a
|
|
128
|
+
// "don't"→"NEVER" reword, a "only"→"sole" reword, a dropped illustrative
|
|
129
|
+
// example). This tokenizer separates what a guardrail actually IS (polarity,
|
|
130
|
+
// scope, load-bearing named facts, required-verbatim wording) from the prose
|
|
131
|
+
// the directive used to explain it (samples, incidental proper nouns), and
|
|
132
|
+
// demands only the former. The failure mode we refuse to introduce is the
|
|
133
|
+
// inverse: a calibration so loose it greenlights a genuinely dropped guardrail.
|
|
134
|
+
// The `truncated_or_drifted` acceptance tests pin that boundary.
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Synonym classes for the scope/polarity modals a guardrail cannot lose. A
|
|
138
|
+
* class is TRIGGERED when the directive uses one of its `trigger` words; the
|
|
139
|
+
* obligation is SATISFIED when the rule contains ANY of the (broader) `satisfy`
|
|
140
|
+
* words. So "don't send" ≡ "NEVER send" ≡ "do not send" (all `neg`), and
|
|
141
|
+
* "only Ken" ≡ "Ken alone" via "sole"/"solely" (`only`). Deliberately small,
|
|
142
|
+
* explicit, and documented — not a thesaurus.
|
|
143
|
+
*
|
|
144
|
+
* Trigger sets are the DISTINCTIVE, unambiguous scope/polarity words only
|
|
145
|
+
* (never/cannot/only/always/…), NOT incidental "no"/"not"/"all"/"each" which
|
|
146
|
+
* appear constantly in non-scope senses and would over-trigger. Satisfy sets
|
|
147
|
+
* are broad so any faithful reword counts. Plain obligation "must"/"shall" is
|
|
148
|
+
* intentionally NOT a class: it marks obligation strength, not polarity or
|
|
149
|
+
* scope, and an imperative reword ("Always call X" / "Confirm before Y")
|
|
150
|
+
* preserves the obligation without the literal token — demanding it survive is
|
|
151
|
+
* a condensation artifact with no safety loss (every rule in the block is
|
|
152
|
+
* already mandatory by construction).
|
|
153
|
+
*
|
|
154
|
+
* SATISFACTION IS SPAN-SCOPED, not whole-rule (PR #4771 adversarial review,
|
|
155
|
+
* MAJOR 1). A satisfy token counts only when it GOVERNS THE SAME SPAN the modal
|
|
156
|
+
* governed in the directive — i.e. it sits within a bounded window of a content
|
|
157
|
+
* word the directive used near its trigger (see {@link ruleSatisfiesModal}).
|
|
158
|
+
* Matching a satisfy token ANYWHERE in the ~400B rule let an INVERTED
|
|
159
|
+
* condensation pass: "Never call Ian the executor without a grant" → "Ian is
|
|
160
|
+
* the executor without a grant" dropped the negation yet satisfied `neg` via the
|
|
161
|
+
* surviving, unrelated "without". Two changes close that:
|
|
162
|
+
* (a) `without` is removed from `neg.satisfy`. It is a PREPOSITION, not a
|
|
163
|
+
* clausal polarity marker — it modifies its own object ("without a grant")
|
|
164
|
+
* and carries no reliable sentence polarity, so it survived the inversion
|
|
165
|
+
* sitting beside the SAME span the lost "never" governed, defeating any
|
|
166
|
+
* proximity check. No genuine condensation relies on a bare "without" as
|
|
167
|
+
* its ONLY negation marker (faithful ones keep "no"/"not"/"never"/"don't").
|
|
168
|
+
* (b) the span/proximity check above (the robust mechanism).
|
|
169
|
+
*
|
|
170
|
+
* NOTE — the review's fallback also proposed dropping `no`/`nor`/`none`/`avoid`
|
|
171
|
+
* (neg) and `just`/`alone`/`purely` (only). Validation against the 5 real M2→M3
|
|
172
|
+
* drafts CONTRADICTED that: carrie condenses "does NOT want …"/"don't ship …"/
|
|
173
|
+
* "No employer-bashing"/"no public URL" into rules that carry the negation as
|
|
174
|
+
* "no X" — 6 FAITHFUL condensations. Dropping `no` re-flagged all 6, reviving the
|
|
175
|
+
* exact false-positive class this gate exists to kill. With the span/proximity
|
|
176
|
+
* check now scoping satisfaction, those reliable negators no longer need
|
|
177
|
+
* dropping (a relocated survivor no longer satisfies), so only the one proven
|
|
178
|
+
* falsifier-enabler (`without`) is removed. Evidence: triage/carrie + the
|
|
179
|
+
* `adversarial false-negatives` acceptance tests.
|
|
180
|
+
*/
|
|
181
|
+
const MODAL_CLASSES = {
|
|
182
|
+
neg: {
|
|
183
|
+
trigger: ["never", "cannot", "can't", "don't", "do not", "won't", "must not", "may not", "shall not"],
|
|
184
|
+
satisfy: ["never", "no", "not", "don't", "dont", "cannot", "can't", "cant", "won't", "wont", "shan't", "none", "nor", "avoid", "refuse", "neither", "prohibit", "forbid", "ban"],
|
|
185
|
+
},
|
|
186
|
+
only: {
|
|
187
|
+
trigger: ["only", "sole", "solely", "exclusively", "nothing but"],
|
|
188
|
+
satisfy: ["only", "sole", "solely", "exclusively", "just", "alone", "purely"],
|
|
189
|
+
},
|
|
190
|
+
universal: {
|
|
191
|
+
// Trigger only on EXPLICIT scope phrases, NOT bare "always". Bare "always"
|
|
192
|
+
// is, like plain "must", usually emphasis on a rule that is already
|
|
193
|
+
// unconditional by construction ("always format X" ⇒ "X"), and condensation
|
|
194
|
+
// legitimately drops it — flagging that is a false positive with no safety
|
|
195
|
+
// loss. Deliberate scope phrases ("in all cases", "without exception") ARE
|
|
196
|
+
// load-bearing and stay mandatory, matched by any universal synonym.
|
|
197
|
+
trigger: ["whenever", "every time", "in all cases", "all cases", "at all times", "in every case", "without exception", "no exception"],
|
|
198
|
+
satisfy: ["always", "every", "each", "all", "everything", "whenever", "any", "must", "never", "no exception", "without exception"],
|
|
199
|
+
},
|
|
200
|
+
} as const;
|
|
201
|
+
|
|
202
|
+
type ModalClass = keyof typeof MODAL_CLASSES;
|
|
203
|
+
|
|
204
|
+
/** Strong required-verbatim cues. A double-quoted string counts as a GUARDRAIL
|
|
205
|
+
* (must survive verbatim) only when one of these immediately precedes it — the
|
|
206
|
+
* directive is telling the agent to emit that exact wording (e.g. a deferral
|
|
207
|
+
* line "…MUST end with exactly:"). Absent a cue, a quoted string is a SAMPLE
|
|
208
|
+
* (toast text, example message) and is illustrative. Kept narrow on purpose:
|
|
209
|
+
* loose cues like "say"/"append" would wrongly promote sample toasts. */
|
|
210
|
+
const VERBATIM_CUE_RE =
|
|
211
|
+
/(?:\bexactly\b|\bverbatim\b|\bword[- ]for[- ]word\b|\bverbatim wording\b|\bliteral(?:ly)?\b|\bthe exact (?:line|wording|text|phrase|words|sentence)\b|\bthese exact words\b|\bexact wording\b)\s*[:,]?\s*["“]?$/i;
|
|
212
|
+
|
|
213
|
+
/** Load-bearing named facts that condensation MUST carry through:
|
|
214
|
+
* - ALL-CAPS name runs (2+ consecutive ALL-CAPS words) — directives SHOUT
|
|
215
|
+
* these because they are load-bearing parties/executors: "GARY DAVID BROWN",
|
|
216
|
+
* "IAN THOMAS GOODFELLOW". Directives ALSO shout for EMPHASIS ("NO HTML",
|
|
217
|
+
* "THREE REFERENCE NUMBERS", "THE VIBE"), so a run is treated as a name only
|
|
218
|
+
* when NONE of its words is a common English word (see EMPHASIS_STOPWORDS).
|
|
219
|
+
* This is an NER-lite pre-filter, not a dictionary; a name built entirely
|
|
220
|
+
* from common words is inherently ambiguous and left to human adjudication.
|
|
221
|
+
* - case / instrument reference codes — a contiguous alnum token mixing an
|
|
222
|
+
* uppercase letter and a digit (AG779131P, TR10399), or a STRUCTURED
|
|
223
|
+
* acronym+number run with ≥2 numeric groups or a ≥5-digit group
|
|
224
|
+
* ("CAV 2026 00037"). Bare acronym+single-small-number ("PR 286") is an
|
|
225
|
+
* incidental reference, not a case code, and is excluded.
|
|
226
|
+
* Both are dropped to ILLUSTRATIVE when they sit in an example context
|
|
227
|
+
* ("e.g. AG779131P", "such as …") — a directive listing sample formats is not
|
|
228
|
+
* asserting a fact the rule must carry. */
|
|
229
|
+
const ALLCAPS_NAME_RE = /\b[A-Z]{2,}(?:\s+[A-Z]{2,}){1,}\b/g;
|
|
230
|
+
const CODE_TOKEN_RE = /\b(?=[A-Z0-9]*[A-Z])(?=[A-Z0-9]*\d)[A-Z0-9]{4,}\b/g;
|
|
231
|
+
const CODE_ACRONYM_NUM_RE = /\b[A-Z]{2,5}(?:\s+\d{2,}){1,}\b/g;
|
|
232
|
+
|
|
233
|
+
/** An `e.g.`/`such as`/`for example` marker in the ~48 chars immediately
|
|
234
|
+
* before a fact match ⇒ the fact is a SAMPLE, not an asserted guardrail. No
|
|
235
|
+
* trailing `\b` — markers ending in `.` ("e.g.") have no word boundary before
|
|
236
|
+
* the following space — and the run after the marker forbids sentence
|
|
237
|
+
* terminators (`.?!:;`) so the marker must be in the SAME clause as the fact.
|
|
238
|
+
*
|
|
239
|
+
* `like` and `including` are DELIBERATELY EXCLUDED (PR #4771 adversarial
|
|
240
|
+
* review, MAJOR 2). Both are far too broad to reliably mark an illustrative
|
|
241
|
+
* sample: "handle probate cases including S CAV 2026 00037" and "parties like
|
|
242
|
+
* GARY DAVID BROWN" name LOAD-BEARING facts, not examples. Treating anything
|
|
243
|
+
* after them as illustrative silently stopped demanding those facts, so a rule
|
|
244
|
+
* that dropped the code/name PASSED the gate. Only the reliable, unambiguous
|
|
245
|
+
* example cues remain (`e.g.`, `i.e.`, `such as`, `for example`,
|
|
246
|
+
* `for instance`, `example(s)`). */
|
|
247
|
+
const EXAMPLE_CONTEXT_RE =
|
|
248
|
+
/(?:\be\.?\s?g\.?|\bi\.?\s?e\.?|\bsuch as|\bfor example|\bfor instance|\bexamples?\b)[^.?!:;]{0,48}$/i;
|
|
249
|
+
|
|
250
|
+
/** Common English words a directive may SHOUT for emphasis rather than name.
|
|
251
|
+
* An ALL-CAPS run containing any of these is emphasis, not a party name.
|
|
252
|
+
* Explicit and bounded on purpose — extend as new emphasis vocabulary shows
|
|
253
|
+
* up in triage, never with plausible surname tokens (BROWN/DAVID/THOMAS stay
|
|
254
|
+
* OUT so real names survive). */
|
|
255
|
+
const EMPHASIS_STOPWORDS = new Set([
|
|
256
|
+
"THE", "A", "AN", "AND", "OR", "BUT", "NOR", "FOR", "SO", "IF", "OF", "TO",
|
|
257
|
+
"IN", "ON", "AT", "BY", "AS", "IS", "ARE", "BE", "NOT", "NO", "ALL", "ANY",
|
|
258
|
+
"EACH", "EVERY", "THIS", "THAT", "IT", "WE", "YOU", "DO", "USE", "PER",
|
|
259
|
+
"VIA", "YES", "NOW", "THEN", "HERE", "WITH", "WITHOUT", "ONLY", "NEVER",
|
|
260
|
+
"ALWAYS", "MUST", "OVER", "UNDER", "ONE", "TWO", "THREE", "FOUR", "FIVE",
|
|
261
|
+
"REFERENCE", "NUMBER", "NUMBERS", "DAILY", "WEEKLY", "MONTHLY", "ROLLING",
|
|
262
|
+
"IMPACT", "VIBE", "LINE", "LINES", "BREAK", "BREAKS", "ROW", "ROWS", "HTML",
|
|
263
|
+
"CSS", "JSON", "YAML", "CODE", "VERBATIM", "LITERAL", "LITERALLY", "AUTO",
|
|
264
|
+
"TOTAL", "NET", "TARGET", "BURN", "FIXED", "RECORDS", "WIN", "SEND",
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
/** True when an ALL-CAPS run reads as a load-bearing proper NAME rather than
|
|
268
|
+
* shouted emphasis. Requires ≥3 words (full legal names — "GARY DAVID BROWN",
|
|
269
|
+
* "IAN THOMAS GOODFELLOW" — clear this; two-word ALL-CAPS is far more often
|
|
270
|
+
* emphasis, e.g. "NO HTML"/"THE VIBE"/"COACHING FRAME", so it is treated as
|
|
271
|
+
* illustrative) AND no word in the common-emphasis stopword set (rejects
|
|
272
|
+
* three-word emphasis like "THREE REFERENCE NUMBERS"/"ROLLING WEEKLY IMPACT").
|
|
273
|
+
* A load-bearing two-word name must survive via a Titlecase mention or a
|
|
274
|
+
* required-verbatim quote (as "Fiona Jessep" does in the deferral line), not
|
|
275
|
+
* this ALL-CAPS pre-filter. */
|
|
276
|
+
function isNameRun(run: string): boolean {
|
|
277
|
+
const words = run.split(/\s+/);
|
|
278
|
+
return words.length >= 3 && words.every((w) => !EMPHASIS_STOPWORDS.has(w));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** True when a structured case/instrument code (≥2 numeric groups or a ≥5-digit
|
|
282
|
+
* group), so an incidental "PR 286" reference does not read as a case number. */
|
|
283
|
+
function isStructuredCode(run: string): boolean {
|
|
284
|
+
const groups = run.match(/\d{2,}/g) ?? [];
|
|
285
|
+
return groups.length >= 2 || groups.some((g) => g.length >= 5);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** True when the char span before `index` is an example-listing context. */
|
|
289
|
+
function inExampleContext(content: string, index: number): boolean {
|
|
290
|
+
return EXAMPLE_CONTEXT_RE.test(content.slice(0, index));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Exclusivity "only"/"sole"/… as a real scope word. Excludes the three ways
|
|
294
|
+
* "only" over-triggered on real drafts, none of which is exclusivity scope:
|
|
295
|
+
* - the "-only" of a compound/directive name ("fiona-facts-only", "step-only");
|
|
296
|
+
* - the temporal "only until/once/then/…" ("lasts only until restart");
|
|
297
|
+
* - the quantifier "only <number>" ("only 2.7% apart", "only 3 items"). */
|
|
298
|
+
const ONLY_TRIGGER_RE =
|
|
299
|
+
/(?<![-\w'’])(?:only|solely|exclusively|sole)\b(?!\s+(?:until|when|once|then|after|before|if|while|as|because|since|about|around|some|roughly|approximately|\d|a\s+few|[½¼¾]))/i;
|
|
300
|
+
|
|
301
|
+
/** Incidental Titlecase proper nouns (Buildkite, Ken, Playwright, Twitter).
|
|
302
|
+
* Extracted so the tokenizer surface stays inspectable, but classed
|
|
303
|
+
* ILLUSTRATIVE and never demanded — the biggest source of the old
|
|
304
|
+
* false-positive rate. A genuinely load-bearing name relies on the ALL-CAPS /
|
|
305
|
+
* code fact patterns above, or on surviving inside a required-verbatim quote. */
|
|
306
|
+
const PROPER_NOUN_STOPWORDS = new Set([
|
|
307
|
+
"The", "A", "An", "This", "That", "These", "Those", "It", "Its", "If",
|
|
308
|
+
"When", "While", "Do", "Don", "Set", "Use", "Never", "Always", "Only",
|
|
309
|
+
"Must", "Not", "No", "Every", "Each", "Any", "All", "For", "And", "But",
|
|
310
|
+
"Or", "So", "Then", "Prefer", "Avoid", "Ask", "Refuse", "Keep", "Treat",
|
|
311
|
+
"Read", "Write", "Run", "Call", "Send", "Reply", "You", "Your", "We",
|
|
312
|
+
"I", "Before", "After",
|
|
313
|
+
]);
|
|
314
|
+
const PROPER_NOUN_RE = /\b[A-Z][a-zA-Z0-9_.-]*[a-z][a-zA-Z0-9_.-]*\b/g;
|
|
315
|
+
|
|
316
|
+
/** Double-quote (straight + curly) and backtick only. Single-quote extraction
|
|
317
|
+
* is DELETED on purpose: `'…'` matched contraction apostrophes ("Ken's own
|
|
318
|
+
* session … don't") and captured enormous spurious "quotes" — the single
|
|
319
|
+
* largest artifact source in the baseline. Real single-quoted phrases do not
|
|
320
|
+
* occur in these directives; the risk is not worth it. */
|
|
321
|
+
const DOUBLE_QUOTE_RE = /["“]([^"”]+)["”]/g;
|
|
322
|
+
const BACKTICK_RE = /`([^`]+)`/g;
|
|
323
|
+
|
|
324
|
+
export interface Keyword {
|
|
325
|
+
/** `modal` = a synonym-classed polarity/scope obligation; `quote` = a quoted
|
|
326
|
+
* string; `fact` = a load-bearing named fact; `proper` = an incidental
|
|
327
|
+
* proper noun. */
|
|
328
|
+
kind: "quote" | "modal" | "fact" | "proper";
|
|
329
|
+
/** Whether the rule MUST preserve this. Only `guardrail` keywords are
|
|
330
|
+
* enforced; `illustrative` keywords are extracted but never demanded. */
|
|
331
|
+
klass: "guardrail" | "illustrative";
|
|
332
|
+
/** The token as it should be searched for / reported (already trimmed). For
|
|
333
|
+
* modals this is the directive's own trigger word (for readable reports). */
|
|
334
|
+
value: string;
|
|
335
|
+
/** For `kind === "modal"`: which synonym class must survive. */
|
|
336
|
+
modalClass?: ModalClass;
|
|
337
|
+
/** For `kind === "modal"`: the content words the directive used near its
|
|
338
|
+
* trigger — the SPAN the modal governed. A surviving satisfy token in the
|
|
339
|
+
* rule counts only when it sits within a bounded window of one of these (see
|
|
340
|
+
* {@link ruleSatisfiesModal}). Empty ⇒ fall back to whole-rule containment
|
|
341
|
+
* (the directive gave no usable anchor, e.g. a bare "Never x."). */
|
|
342
|
+
anchors?: string[];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function normalize(s: string): string {
|
|
346
|
+
return s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function escapeRe(s: string): string {
|
|
350
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Word-boundary containment for a single alnum-ish token (apostrophes treated
|
|
354
|
+
* as punctuation, so `cannot` is not satisfied by `cannon` and `never` is not
|
|
355
|
+
* satisfied by `nevertheless`). */
|
|
356
|
+
function containsWord(normalizedText: string, word: string): boolean {
|
|
357
|
+
const stem = normalize(word).replace(/['’]/g, "'");
|
|
358
|
+
if (/\s/.test(stem)) return normalizedText.includes(stem);
|
|
359
|
+
const re = new RegExp(`(^|[^a-z0-9])${escapeRe(stem)}([^a-z0-9]|$)`);
|
|
360
|
+
return re.test(normalizedText);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** True when the directive triggers `cls` (uses one of its trigger words). The
|
|
364
|
+
* `only` class uses a refined matcher that ignores "-only" compounds and
|
|
365
|
+
* temporal "only until/once/…" (both over-triggered on real drafts). */
|
|
366
|
+
function directiveTriggersModal(normalizedContent: string, cls: ModalClass): boolean {
|
|
367
|
+
if (cls === "only") return ONLY_TRIGGER_RE.test(normalizedContent);
|
|
368
|
+
return MODAL_CLASSES[cls].trigger.some((w) => containsWord(normalizedContent, w));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- Span/proximity scoping for modal satisfaction (PR #4771 MAJOR 1) --------
|
|
372
|
+
//
|
|
373
|
+
// A modal obligation is satisfied only when a surviving satisfy token GOVERNS
|
|
374
|
+
// THE SAME SPAN it governed in the directive. We approximate "same span" by the
|
|
375
|
+
// content words the directive used within a window of its trigger (the modal's
|
|
376
|
+
// ANCHORS); the satisfy token in the rule must sit within a window of one of
|
|
377
|
+
// those anchors. Generous windows: the goal is to catch an inverted/relocated
|
|
378
|
+
// negation, not to re-introduce condensation false positives — a faithful
|
|
379
|
+
// reword keeps the modal beside the thing it negates/scopes.
|
|
380
|
+
|
|
381
|
+
/** Half-width, in characters, of the proximity window on both the directive
|
|
382
|
+
* (anchor harvest) and rule (satisfy match) sides. ~One clause of a ~400B
|
|
383
|
+
* rule; wide enough that a faithful reword passes, tight enough that a modal
|
|
384
|
+
* relocated to an unrelated clause does not. */
|
|
385
|
+
const MODAL_SPAN_WINDOW = 120;
|
|
386
|
+
|
|
387
|
+
/** Function/scaffolding words that make poor anchors — too common to pin a span
|
|
388
|
+
* and prone to spurious matches. Anchors must be DISTINCTIVE content words. */
|
|
389
|
+
const ANCHOR_STOPWORDS = new Set([
|
|
390
|
+
"this", "that", "these", "those", "with", "without", "from", "into", "onto",
|
|
391
|
+
"your", "their", "there", "here", "then", "than", "them", "they", "some",
|
|
392
|
+
"such", "each", "every", "which", "what", "when", "where", "while", "about",
|
|
393
|
+
"before", "after", "over", "under", "above", "below", "must", "should",
|
|
394
|
+
"would", "could", "shall", "will", "have", "been", "being", "does", "done",
|
|
395
|
+
"also", "only", "just", "very", "much", "more", "most", "less", "least",
|
|
396
|
+
"any", "all", "both", "either", "neither", "none", "unless", "until",
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
/** The character offset of `cls`'s first trigger occurrence in the (normalized)
|
|
400
|
+
* directive, or -1. `only` uses the refined, sense-aware matcher. */
|
|
401
|
+
function firstTriggerIndex(normalizedContent: string, cls: ModalClass): number {
|
|
402
|
+
if (cls === "only") {
|
|
403
|
+
const m = ONLY_TRIGGER_RE.exec(normalizedContent);
|
|
404
|
+
return m ? m.index : -1;
|
|
405
|
+
}
|
|
406
|
+
let best = -1;
|
|
407
|
+
for (const w of MODAL_CLASSES[cls].trigger) {
|
|
408
|
+
const stem = normalize(w).replace(/['’]/g, "'");
|
|
409
|
+
const re = new RegExp(`(?:^|[^a-z0-9])(${escapeRe(stem)})(?:[^a-z0-9]|$)`);
|
|
410
|
+
const m = re.exec(normalizedContent);
|
|
411
|
+
if (m) {
|
|
412
|
+
const idx = m.index + m[0].indexOf(m[1]);
|
|
413
|
+
if (best === -1 || idx < best) best = idx;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return best;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Distinctive content words (≥4 chars, not a stopword) within
|
|
420
|
+
* ±MODAL_SPAN_WINDOW of `centerIdx` — the span the modal governs. */
|
|
421
|
+
function anchorsAround(normalizedContent: string, centerIdx: number): string[] {
|
|
422
|
+
if (centerIdx < 0) return [];
|
|
423
|
+
const lo = Math.max(0, centerIdx - MODAL_SPAN_WINDOW);
|
|
424
|
+
const hi = Math.min(normalizedContent.length, centerIdx + MODAL_SPAN_WINDOW);
|
|
425
|
+
const window = normalizedContent.slice(lo, hi);
|
|
426
|
+
const words = window.match(/[a-z0-9]{4,}/g) ?? [];
|
|
427
|
+
const out: string[] = [];
|
|
428
|
+
const seen = new Set<string>();
|
|
429
|
+
for (const w of words) {
|
|
430
|
+
if (ANCHOR_STOPWORDS.has(w) || seen.has(w)) continue;
|
|
431
|
+
seen.add(w);
|
|
432
|
+
out.push(w);
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Anchors for a triggered modal class: the span words around its trigger. */
|
|
438
|
+
function modalAnchors(normalizedContent: string, cls: ModalClass): string[] {
|
|
439
|
+
return anchorsAround(normalizedContent, firstTriggerIndex(normalizedContent, cls));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** All character offsets at which `word` occurs as a whole word in `text`. */
|
|
443
|
+
function wordOffsets(text: string, word: string): number[] {
|
|
444
|
+
const stem = normalize(word).replace(/['’]/g, "'");
|
|
445
|
+
const offsets: number[] = [];
|
|
446
|
+
if (/\s/.test(stem)) {
|
|
447
|
+
let i = text.indexOf(stem);
|
|
448
|
+
while (i !== -1) {
|
|
449
|
+
offsets.push(i);
|
|
450
|
+
i = text.indexOf(stem, i + 1);
|
|
451
|
+
}
|
|
452
|
+
return offsets;
|
|
453
|
+
}
|
|
454
|
+
const re = new RegExp(`(^|[^a-z0-9])(${escapeRe(stem)})([^a-z0-9]|$)`, "g");
|
|
455
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
456
|
+
offsets.push(m.index + m[1].length);
|
|
457
|
+
re.lastIndex = m.index + 1; // allow overlapping / adjacent matches
|
|
458
|
+
}
|
|
459
|
+
return offsets;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* True when the rule satisfies `cls`. A satisfy token must both (i) be present
|
|
464
|
+
* as a whole word and (ii) sit within ±MODAL_SPAN_WINDOW of one of the modal's
|
|
465
|
+
* `anchors` — the span the directive's modal governed. When `anchors` is empty
|
|
466
|
+
* (the directive gave no distinctive span word, e.g. a bare "Never x."), it
|
|
467
|
+
* falls back to whole-rule containment so short guardrails still match.
|
|
468
|
+
*
|
|
469
|
+
* This is the fix for PR #4771 MAJOR 1: whole-rule containment let an unrelated
|
|
470
|
+
* same-class survivor (a relocated "without"/"not") satisfy a modal whose actual
|
|
471
|
+
* proposition the condensation had dropped or inverted.
|
|
472
|
+
*/
|
|
473
|
+
function ruleSatisfiesModal(
|
|
474
|
+
normalizedRuleText: string,
|
|
475
|
+
cls: ModalClass,
|
|
476
|
+
anchors: readonly string[] = [],
|
|
477
|
+
): boolean {
|
|
478
|
+
const tokens = MODAL_CLASSES[cls].satisfy;
|
|
479
|
+
if (anchors.length === 0) {
|
|
480
|
+
return tokens.some((w) => containsWord(normalizedRuleText, w));
|
|
481
|
+
}
|
|
482
|
+
for (const tok of tokens) {
|
|
483
|
+
for (const at of wordOffsets(normalizedRuleText, tok)) {
|
|
484
|
+
const lo = Math.max(0, at - MODAL_SPAN_WINDOW);
|
|
485
|
+
const hi = Math.min(normalizedRuleText.length, at + tok.length + MODAL_SPAN_WINDOW);
|
|
486
|
+
const window = normalizedRuleText.slice(lo, hi);
|
|
487
|
+
if (anchors.some((a) => window.includes(a))) return true;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** First trigger word the directive used for `cls`, for readable reporting. */
|
|
494
|
+
function firstTrigger(normalizedContent: string, cls: ModalClass): string {
|
|
495
|
+
return MODAL_CLASSES[cls].trigger.find((w) => containsWord(normalizedContent, w)) ?? cls;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Extract the calibrated keyword set from a directive's content. Deterministic.
|
|
500
|
+
* Each keyword carries a GUARDRAIL/ILLUSTRATIVE class; only guardrail keywords
|
|
501
|
+
* are enforced against the rule (see {@link ruleContainsKeyword} and the drift
|
|
502
|
+
* loop). Exported for direct unit testing.
|
|
503
|
+
*/
|
|
504
|
+
export function extractKeywords(content: string): Keyword[] {
|
|
505
|
+
const out: Keyword[] = [];
|
|
506
|
+
const seen = new Set<string>();
|
|
507
|
+
const push = (kw: Keyword) => {
|
|
508
|
+
const value = kw.value.trim();
|
|
509
|
+
if (value.length === 0) return;
|
|
510
|
+
const key = `${kw.kind}:${value.toLowerCase()}`;
|
|
511
|
+
if (seen.has(key)) return;
|
|
512
|
+
seen.add(key);
|
|
513
|
+
out.push({ ...kw, value });
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const norm = normalize(content);
|
|
517
|
+
|
|
518
|
+
// (1) Modal synonym classes — guardrail. One keyword per triggered class.
|
|
519
|
+
// `anchors` pin the span the modal governed so satisfaction is span-scoped
|
|
520
|
+
// (a relocated same-class survivor no longer satisfies) — PR #4771 MAJOR 1.
|
|
521
|
+
for (const cls of Object.keys(MODAL_CLASSES) as ModalClass[]) {
|
|
522
|
+
if (directiveTriggersModal(norm, cls)) {
|
|
523
|
+
push({
|
|
524
|
+
kind: "modal",
|
|
525
|
+
klass: "guardrail",
|
|
526
|
+
value: firstTrigger(norm, cls),
|
|
527
|
+
modalClass: cls,
|
|
528
|
+
anchors: modalAnchors(norm, cls),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// (2) Quoted strings. Backtick = code sample ⇒ illustrative. Double-quote =
|
|
534
|
+
// guardrail only when a required-verbatim cue immediately precedes it.
|
|
535
|
+
for (const m of content.matchAll(DOUBLE_QUOTE_RE)) {
|
|
536
|
+
const before = content.slice(0, m.index ?? 0);
|
|
537
|
+
const cued = VERBATIM_CUE_RE.test(before);
|
|
538
|
+
push({ kind: "quote", klass: cued ? "guardrail" : "illustrative", value: m[1] });
|
|
539
|
+
}
|
|
540
|
+
for (const m of content.matchAll(BACKTICK_RE)) {
|
|
541
|
+
push({ kind: "quote", klass: "illustrative", value: m[1] });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// (3) Load-bearing facts — guardrail: ALL-CAPS proper-name runs and
|
|
545
|
+
// case/instrument codes a condensed rule cannot silently drop. Emphasis
|
|
546
|
+
// ALL-CAPS, incidental "PR 286" refs, and example-listed codes are
|
|
547
|
+
// downgraded to illustrative (see the helpers above).
|
|
548
|
+
const fact = (value: string, index: number, guardrail: boolean) =>
|
|
549
|
+
push({ kind: "fact", klass: guardrail && !inExampleContext(content, index) ? "guardrail" : "illustrative", value });
|
|
550
|
+
for (const m of content.matchAll(ALLCAPS_NAME_RE)) fact(m[0], m.index ?? 0, isNameRun(m[0]));
|
|
551
|
+
for (const m of content.matchAll(CODE_TOKEN_RE)) fact(m[0], m.index ?? 0, true);
|
|
552
|
+
for (const m of content.matchAll(CODE_ACRONYM_NUM_RE)) fact(m[0], m.index ?? 0, isStructuredCode(m[0]));
|
|
553
|
+
|
|
554
|
+
// (4) Incidental Titlecase proper nouns — illustrative (extracted, not demanded).
|
|
555
|
+
for (const m of content.matchAll(PROPER_NOUN_RE)) {
|
|
556
|
+
const w = m[0];
|
|
557
|
+
if (PROPER_NOUN_STOPWORDS.has(w)) continue;
|
|
558
|
+
push({ kind: "proper", klass: "illustrative", value: w });
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** True when `ruleText` (normalized) preserves the GUARDRAIL keyword. Modals
|
|
564
|
+
* are satisfied by any member of their synonym class; quoted/fact phrases are
|
|
565
|
+
* substring-matched (case-insensitive); single tokens are word-boundary
|
|
566
|
+
* matched. Illustrative keywords are always treated as preserved (never
|
|
567
|
+
* demanded). */
|
|
568
|
+
function ruleContainsKeyword(normalizedRuleText: string, kw: Keyword): boolean {
|
|
569
|
+
if (kw.klass === "illustrative") return true;
|
|
570
|
+
if (kw.kind === "modal" && kw.modalClass) {
|
|
571
|
+
return ruleSatisfiesModal(normalizedRuleText, kw.modalClass, kw.anchors ?? []);
|
|
572
|
+
}
|
|
573
|
+
const needle = normalize(kw.value);
|
|
574
|
+
if (/\s/.test(needle)) return normalizedRuleText.includes(needle);
|
|
575
|
+
return containsWord(normalizedRuleText, needle);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** A rule text looks truncated when it ends in an ellipsis, or is a strict
|
|
579
|
+
* prefix of the directive content (normalized) — i.e. it was cut short. */
|
|
580
|
+
function looksTruncated(ruleText: string, directiveContent: string): boolean {
|
|
581
|
+
const t = ruleText.trim();
|
|
582
|
+
if (t.endsWith("…") || t.endsWith("...")) return true;
|
|
583
|
+
const nr = normalize(ruleText);
|
|
584
|
+
const nc = normalize(directiveContent);
|
|
585
|
+
return nr.length > 0 && nc.length > nr.length && nc.startsWith(nr);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
// The check
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
/** The active residue subset of the injected directives. When a directive
|
|
593
|
+
* carries a `category`, it must be a residue category; an explicit
|
|
594
|
+
* `isActive: false` excludes it. */
|
|
595
|
+
export function residueDirectives(directives: readonly FlipDirective[]): FlipDirective[] {
|
|
596
|
+
return directives.filter((d) => {
|
|
597
|
+
if (d.isActive === false) return false;
|
|
598
|
+
if (d.category !== undefined) return RESIDUE_CATEGORIES.has(d.category);
|
|
599
|
+
return true;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function compareDirectivesToRules(
|
|
604
|
+
directives: readonly FlipDirective[],
|
|
605
|
+
parsedRules: ParsedRulesBlock | null,
|
|
606
|
+
mapping: DirectiveRuleMapping,
|
|
607
|
+
): EquivalenceReport {
|
|
608
|
+
const rules: Rule[] = parsedRules?.rules ?? [];
|
|
609
|
+
const rulesById = new Map<string, Rule>(rules.map((r) => [r.id, r]));
|
|
610
|
+
const residue = residueDirectives(directives);
|
|
611
|
+
|
|
612
|
+
const missing_from_rules: MissingEntry[] = [];
|
|
613
|
+
const truncated_or_drifted: DriftEntry[] = [];
|
|
614
|
+
const sourcedRuleIds = new Set<string>();
|
|
615
|
+
|
|
616
|
+
for (const d of residue) {
|
|
617
|
+
const target = mapping[d.id];
|
|
618
|
+
if (target === undefined) {
|
|
619
|
+
missing_from_rules.push({ id: d.id, name: d.name, reason: "unmapped" });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (target.startsWith(RETIRED_PREFIX)) {
|
|
623
|
+
const reason = target.slice(RETIRED_PREFIX.length).trim();
|
|
624
|
+
if (reason.length === 0) {
|
|
625
|
+
missing_from_rules.push({
|
|
626
|
+
id: d.id,
|
|
627
|
+
name: d.name,
|
|
628
|
+
reason: "empty-retire-reason",
|
|
629
|
+
mappedTo: target,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
// A valid `retired:<reason>` is a satisfied obligation — no rule needed.
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const rule = rulesById.get(target);
|
|
636
|
+
if (!rule) {
|
|
637
|
+
missing_from_rules.push({
|
|
638
|
+
id: d.id,
|
|
639
|
+
name: d.name,
|
|
640
|
+
reason: "absent-rule",
|
|
641
|
+
mappedTo: target,
|
|
642
|
+
});
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
sourcedRuleIds.add(rule.id);
|
|
646
|
+
|
|
647
|
+
// (b) drift / truncation.
|
|
648
|
+
const normalizedRuleText = normalize(rule.text);
|
|
649
|
+
const missingKeywords = extractKeywords(d.content)
|
|
650
|
+
.filter((kw) => !ruleContainsKeyword(normalizedRuleText, kw))
|
|
651
|
+
.map((kw) => kw.value);
|
|
652
|
+
const truncated = looksTruncated(rule.text, d.content);
|
|
653
|
+
if (missingKeywords.length > 0 || truncated) {
|
|
654
|
+
truncated_or_drifted.push({
|
|
655
|
+
id: d.id,
|
|
656
|
+
name: d.name,
|
|
657
|
+
ruleId: rule.id,
|
|
658
|
+
missingKeywords,
|
|
659
|
+
truncated,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// (d) reverse: every rule must have a directive sourcing it.
|
|
665
|
+
const unsourced_rules: UnsourcedRule[] = rules
|
|
666
|
+
.filter((r) => !sourcedRuleIds.has(r.id))
|
|
667
|
+
.map((r) => ({ id: r.id, text: r.text }));
|
|
668
|
+
|
|
669
|
+
// (c) budget + sentinel integrity.
|
|
670
|
+
const rendered = renderRulesBlock(rules);
|
|
671
|
+
const renderedBytes = Buffer.byteLength(rendered, "utf8");
|
|
672
|
+
const withinBudget = renderedBytes <= RULES_BLOCK_BUDGET_BYTES;
|
|
673
|
+
const sentinelCount = parsedRules?.sentinel?.count ?? null;
|
|
674
|
+
const ruleCount = rules.length;
|
|
675
|
+
const sentinelMatchesCount = sentinelCount === ruleCount;
|
|
676
|
+
|
|
677
|
+
const pass =
|
|
678
|
+
missing_from_rules.length === 0 &&
|
|
679
|
+
truncated_or_drifted.length === 0 &&
|
|
680
|
+
unsourced_rules.length === 0 &&
|
|
681
|
+
withinBudget &&
|
|
682
|
+
sentinelMatchesCount;
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
pass,
|
|
686
|
+
missing_from_rules,
|
|
687
|
+
truncated_or_drifted,
|
|
688
|
+
unsourced_rules,
|
|
689
|
+
renderedBytes,
|
|
690
|
+
budgetBytes: RULES_BLOCK_BUDGET_BYTES,
|
|
691
|
+
withinBudget,
|
|
692
|
+
sentinelCount,
|
|
693
|
+
ruleCount,
|
|
694
|
+
sentinelMatchesCount,
|
|
695
|
+
residueDirectiveCount: residue.length,
|
|
696
|
+
};
|
|
697
|
+
}
|