rulereceipt 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/checks/classify.js +42 -0
- package/dist/cli.js +23 -2
- package/dist/overrides.d.ts +82 -0
- package/dist/overrides.js +134 -0
- package/dist/report/generateHtmlReport.js +23 -6
- package/dist/report/generateReport.js +19 -0
- package/dist/types.d.ts +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -93,6 +93,38 @@ on a session it never found.
|
|
|
93
93
|
For most people the honest answer is simpler: run `rulereceipt check --html`
|
|
94
94
|
locally and attach the report to the PR.
|
|
95
95
|
|
|
96
|
+
## Correcting a misclassification
|
|
97
|
+
|
|
98
|
+
If the tool treats something in your rules file as a rule when it isn't,
|
|
99
|
+
create `.rulereceipt.json` in your project:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"overrides": [
|
|
104
|
+
{ "rule": "12", "reason": "changelog entry, not a rule", "date": "2026-08-31" }
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Commit it. An override should be visible in code review, not just in the
|
|
110
|
+
report.
|
|
111
|
+
|
|
112
|
+
**An override cannot hide a violation, by design.** The check still runs,
|
|
113
|
+
keeps its real result, and the override only adds a label. So the report
|
|
114
|
+
shows the rule, its true result, your reason, and a line saying what the
|
|
115
|
+
result would have been without the override. The headline count includes
|
|
116
|
+
overridden failures, and the exit code still fails on them — otherwise CI
|
|
117
|
+
would be the loophole.
|
|
118
|
+
|
|
119
|
+
The worst an override can do is draw a labelled box around a real
|
|
120
|
+
violation and sign it with your reason. That leaves a reader better
|
|
121
|
+
informed than a plain failure would, not worse.
|
|
122
|
+
|
|
123
|
+
A reason is required; an override without one is refused rather than
|
|
124
|
+
applied quietly. If a rule id exists in both your global and project
|
|
125
|
+
rules files, write `"project:12"` or `"global:12"` — a bare id that
|
|
126
|
+
matches both is refused rather than silently disabling both.
|
|
127
|
+
|
|
96
128
|
## Sharing a report
|
|
97
129
|
|
|
98
130
|
`rulereceipt check --html` writes one self-contained HTML file. No
|
|
@@ -128,6 +160,7 @@ rulereceipt check --html # write a shareable single-file HTML report you c
|
|
|
128
160
|
rulereceipt check --html report.html # ...to a specific path
|
|
129
161
|
rulereceipt check --require-session # fail if there's no session, instead of passing silently
|
|
130
162
|
rulereceipt check --exit-zero # report failures without failing the build
|
|
163
|
+
# .rulereceipt.json # mark a misclassified item as not-a-rule (see below)
|
|
131
164
|
rulereceipt check --llm # opt-in: grade judgment rules with your own Claude key
|
|
132
165
|
rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
|
|
133
166
|
rulereceipt check --telemetry # opt-in: send one random per-machine ID
|
package/dist/checks/classify.js
CHANGED
|
@@ -31,7 +31,49 @@ const IMPERATIVE_INSTRUCTION = /(?:^|[.;:!?]\s+|^\s*[-*+]\s*|\n\s*[-*+]\s*)(use|
|
|
|
31
31
|
* with no instruction in it has nothing to check compliance against,
|
|
32
32
|
* whatever its punctuation.
|
|
33
33
|
*/
|
|
34
|
+
/**
|
|
35
|
+
* A section whose TITLE announces a record of something that happened —
|
|
36
|
+
* an incident, a postmortem, a retrospective. These are written to
|
|
37
|
+
* explain history, not to instruct the agent.
|
|
38
|
+
*
|
|
39
|
+
* Found by running this tool on a real session (2026-08-31): a section
|
|
40
|
+
* titled "Real incident (2026-08-28): Vercel had the same office/personal
|
|
41
|
+
* mixup" was enforced as a REQUIRE rule whose pattern was an employer
|
|
42
|
+
* name pulled out of the narrative, so the report announced FOLLOWED
|
|
43
|
+
* because that name appeared somewhere in the session. The rule being
|
|
44
|
+
* "satisfied" was a sentence describing a past mistake.
|
|
45
|
+
*
|
|
46
|
+
* The existing directive test cannot catch these, and correctly so: a
|
|
47
|
+
* good incident note ends with the lesson ("Verify with `vercel whoami`
|
|
48
|
+
* before every deploy"), so it genuinely does contain a directive. What
|
|
49
|
+
* the section IS gets announced by its title, which is where this looks.
|
|
50
|
+
*
|
|
51
|
+
* Kept to a small closed class of words that name a record of an event,
|
|
52
|
+
* in the same spirit as DIRECTIVE_LANGUAGE above — a bounded property of
|
|
53
|
+
* language, not an enumeration of document formats. Enumerating formats
|
|
54
|
+
* is the mistake this project already made once and wrote up publicly.
|
|
55
|
+
*/
|
|
56
|
+
const EVENT_RECORD_TITLE = /\b(incident|post-?mortem|retro(spective)?|outage|what went wrong)\b/i;
|
|
57
|
+
/**
|
|
58
|
+
* A title that OPENS with an instruction is a rule, whatever it goes on
|
|
59
|
+
* to mention. "Never repeat the 2026-08-28 incident" is a directive that
|
|
60
|
+
* happens to name an incident; "Real incident (2026-08-28): ..." is a
|
|
61
|
+
* report that happens to contain the word never further along.
|
|
62
|
+
*/
|
|
63
|
+
const TITLE_OPENS_WITH_DIRECTIVE = /^\s*[-*+\d.\s]*(never|always|must|do not|don't|dont|avoid|ensure|prefer|only|make sure|be sure)\b/i;
|
|
64
|
+
function isEventRecord(rule) {
|
|
65
|
+
if (TITLE_OPENS_WITH_DIRECTIVE.test(rule.title))
|
|
66
|
+
return false;
|
|
67
|
+
if (IMPERATIVE_INSTRUCTION.test(rule.title))
|
|
68
|
+
return false;
|
|
69
|
+
return EVENT_RECORD_TITLE.test(rule.title);
|
|
70
|
+
}
|
|
34
71
|
function isNotARule(rule) {
|
|
72
|
+
// Checked before the directive test on purpose: an incident note that
|
|
73
|
+
// ends with its lesson contains a real directive, and would otherwise
|
|
74
|
+
// be enforced as though the history itself were the rule.
|
|
75
|
+
if (isEventRecord(rule))
|
|
76
|
+
return true;
|
|
35
77
|
const combined = `${rule.title} ${rule.text}`;
|
|
36
78
|
if (DIRECTIVE_LANGUAGE.test(combined))
|
|
37
79
|
return false;
|
package/dist/cli.js
CHANGED
|
@@ -26,6 +26,7 @@ import { enableSchedule, disableSchedule, scheduleStatus } from "./schedule.js";
|
|
|
26
26
|
import { findSplitBrainConflicts } from "./checks/splitBrain.js";
|
|
27
27
|
import { runDoctor } from "./checks/doctor.js";
|
|
28
28
|
import { sendTelemetryPing, isTelemetryEnabled } from "./telemetry.js";
|
|
29
|
+
import { loadOverrides, resolveOverrides } from "./overrides.js";
|
|
29
30
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
31
|
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
31
32
|
const SHARE_ENDPOINT = "https://rulereceipt.dev/api/share";
|
|
@@ -123,7 +124,9 @@ function writeHtmlReport(results, meta, cwd, target) {
|
|
|
123
124
|
// name, an office email, and absolute paths. Home paths are redacted
|
|
124
125
|
// automatically; nothing else can be, so say so plainly at the moment
|
|
125
126
|
// the file is created rather than burying it in a policy page.
|
|
126
|
-
console.log("
|
|
127
|
+
console.log("\n⚠ This report includes your rule text and session evidence VERBATIM.\n" +
|
|
128
|
+
" Review it before sharing outside your team — only you know what's in your rules file.\n" +
|
|
129
|
+
" Nothing is auto-redacted: this tool cannot tell which of your own rules are sensitive.");
|
|
127
130
|
}
|
|
128
131
|
catch (err) {
|
|
129
132
|
console.log(`\n(--html: couldn't write ${outPath} — ${err instanceof Error ? err.message : String(err)})`);
|
|
@@ -218,7 +221,22 @@ async function runCheck(opts) {
|
|
|
218
221
|
// sends only a random install ID, never rule text or transcript content,
|
|
219
222
|
// regardless of --llm.
|
|
220
223
|
const judgmentResults = llm ? await runJudgmentChecks(judgment, events) : judgment.map(({ rule }) => needsLlmResult(rule));
|
|
221
|
-
const
|
|
224
|
+
const computed = [...deterministicResults, ...judgmentResults];
|
|
225
|
+
// Applied AFTER every check has run, and it only attaches a label.
|
|
226
|
+
// Nothing is skipped and no status is changed: an override that could
|
|
227
|
+
// suppress a check would let anyone delete their own violations, which
|
|
228
|
+
// is precisely what this design refuses to allow. See src/overrides.ts.
|
|
229
|
+
// Resolved against the rules actually present, and scoped by source: a
|
|
230
|
+
// global and a project rules file can both define "Rule 1", and a bare
|
|
231
|
+
// id would otherwise silently disable both.
|
|
232
|
+
const { bySourceAndId: overrides, problems: overrideProblems } = resolveOverrides(loadOverrides(cwd), computed.map((r) => ({ ruleId: r.ruleId, ruleSource: r.ruleSource })));
|
|
233
|
+
const results = computed.map((r) => {
|
|
234
|
+
const o = overrides.get(`${r.ruleSource}:${r.ruleId}`);
|
|
235
|
+
return o ? { ...r, overriddenReason: o.reason, overriddenDate: o.date } : r;
|
|
236
|
+
});
|
|
237
|
+
for (const problem of overrideProblems) {
|
|
238
|
+
console.log(`\n(${problem})`);
|
|
239
|
+
}
|
|
222
240
|
const meta = { sessionFilePath, ruleCount: results.length };
|
|
223
241
|
const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
|
|
224
242
|
console.log(reportText);
|
|
@@ -260,6 +278,9 @@ async function runCheck(opts) {
|
|
|
260
278
|
// rules in a real CLAUDE.md need judgment, so without --llm they
|
|
261
279
|
// legitimately report UNCLEAR. Gating on those would make every build
|
|
262
280
|
// red on day one and the check would be deleted within a week.
|
|
281
|
+
// Deliberately reads .status, which an override never changes. If an
|
|
282
|
+
// overridden failure exited 0, CI would become the loophole this whole
|
|
283
|
+
// design exists to close: mark the rule, get a green build, done.
|
|
263
284
|
if (!exitZero && results.some((r) => r.status === "FAIL")) {
|
|
264
285
|
process.exitCode = 1;
|
|
265
286
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project overrides: a way to say "this item in my rules file isn't
|
|
3
|
+
* actually a rule for my project" without waiting for an upstream release.
|
|
4
|
+
*
|
|
5
|
+
* THE SECURITY PROPERTY THAT MAKES THIS SAFE
|
|
6
|
+
*
|
|
7
|
+
* An override never changes whether a check runs, and never changes its
|
|
8
|
+
* result. The check executes exactly as it would have, keeps its real
|
|
9
|
+
* status, and the override only changes how the result is PRESENTED.
|
|
10
|
+
*
|
|
11
|
+
* That distinction is the whole design. The obvious version of this
|
|
12
|
+
* feature — "let the user mark a rule as not-a-rule, and skip it" — is
|
|
13
|
+
* not safe, and restricting the direction of the override does not make
|
|
14
|
+
* it safe:
|
|
15
|
+
*
|
|
16
|
+
* Rule: "Never commit directly to main"
|
|
17
|
+
* Agent: commits directly to main -> FAIL
|
|
18
|
+
* User: marks it "not a rule" -> not checked
|
|
19
|
+
* Report: the violation is gone
|
|
20
|
+
*
|
|
21
|
+
* Nobody had to claim a pass. They deleted the question instead. So this
|
|
22
|
+
* implementation refuses to delete questions. The worst an override can
|
|
23
|
+
* do is draw a labelled box around a real violation and sign it with a
|
|
24
|
+
* reason and a date — which leaves a reader BETTER informed than a plain
|
|
25
|
+
* failure would, not worse.
|
|
26
|
+
*
|
|
27
|
+
* Consequences enforced elsewhere, and deliberately not weakened:
|
|
28
|
+
* - the exit code still fails on an overridden violation (cli.ts), or
|
|
29
|
+
* CI becomes the loophole this whole design exists to close;
|
|
30
|
+
* - the report's headline verdict counts overridden failures, or the
|
|
31
|
+
* one line everyone reads would be the one line that lies.
|
|
32
|
+
*
|
|
33
|
+
* A reason is mandatory. An override without one is refused, not applied
|
|
34
|
+
* silently: it costs a sentence to write, and it is the part a reviewer
|
|
35
|
+
* actually reads.
|
|
36
|
+
*
|
|
37
|
+
* AMBIGUOUS IDS FAIL CLOSED. A global CLAUDE.md and a project one can
|
|
38
|
+
* legitimately both contain a "Rule 1" — the report already disambiguates
|
|
39
|
+
* those on collision. An override written as `"rule": "1"` when two rules
|
|
40
|
+
* share that id would silently disable BOTH, including one the user never
|
|
41
|
+
* meant to touch. Found while testing this feature against a real machine
|
|
42
|
+
* that has a global rules file. So a bare id is applied only when it is
|
|
43
|
+
* unambiguous; when it is not, the override is refused and the user is
|
|
44
|
+
* told to write `"project:1"` or `"global:1"` instead.
|
|
45
|
+
*/
|
|
46
|
+
export declare const OVERRIDES_FILE = ".rulereceipt.json";
|
|
47
|
+
export interface RuleOverride {
|
|
48
|
+
/** Rule id as it appears in the report, e.g. "12" or "S7.0". */
|
|
49
|
+
rule: string;
|
|
50
|
+
/** Why this isn't a rule for this project. Required — never optional. */
|
|
51
|
+
reason: string;
|
|
52
|
+
/** Optional ISO date, shown in the report so a reader can judge staleness. */
|
|
53
|
+
date?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface LoadedOverrides {
|
|
56
|
+
/** Keyed by the raw id as written by the user ("1" or "project:1"). */
|
|
57
|
+
byRuleId: Map<string, RuleOverride>;
|
|
58
|
+
/** Problems worth telling the user about — malformed entries, missing reasons. */
|
|
59
|
+
problems: string[];
|
|
60
|
+
}
|
|
61
|
+
/** A rule as the report identifies it, used to resolve an override target. */
|
|
62
|
+
export interface OverrideTarget {
|
|
63
|
+
ruleId: string;
|
|
64
|
+
ruleSource: "global" | "project";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolves override entries against the rules actually present, and
|
|
68
|
+
* refuses anything ambiguous rather than guessing which rule was meant.
|
|
69
|
+
* Returns a lookup keyed by `${source}:${id}`, plus any new problems.
|
|
70
|
+
*/
|
|
71
|
+
export declare function resolveOverrides(loaded: LoadedOverrides, targets: OverrideTarget[]): {
|
|
72
|
+
bySourceAndId: Map<string, RuleOverride>;
|
|
73
|
+
problems: string[];
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Reads and validates the override file. Never throws: a broken override
|
|
77
|
+
* file must not take down a check that would otherwise have worked, and
|
|
78
|
+
* an unreadable file means "no overrides", never "override everything".
|
|
79
|
+
* Every rejection is reported rather than swallowed, so a user whose
|
|
80
|
+
* override isn't working finds out why.
|
|
81
|
+
*/
|
|
82
|
+
export declare function loadOverrides(cwd: string): LoadedOverrides;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Per-project overrides: a way to say "this item in my rules file isn't
|
|
5
|
+
* actually a rule for my project" without waiting for an upstream release.
|
|
6
|
+
*
|
|
7
|
+
* THE SECURITY PROPERTY THAT MAKES THIS SAFE
|
|
8
|
+
*
|
|
9
|
+
* An override never changes whether a check runs, and never changes its
|
|
10
|
+
* result. The check executes exactly as it would have, keeps its real
|
|
11
|
+
* status, and the override only changes how the result is PRESENTED.
|
|
12
|
+
*
|
|
13
|
+
* That distinction is the whole design. The obvious version of this
|
|
14
|
+
* feature — "let the user mark a rule as not-a-rule, and skip it" — is
|
|
15
|
+
* not safe, and restricting the direction of the override does not make
|
|
16
|
+
* it safe:
|
|
17
|
+
*
|
|
18
|
+
* Rule: "Never commit directly to main"
|
|
19
|
+
* Agent: commits directly to main -> FAIL
|
|
20
|
+
* User: marks it "not a rule" -> not checked
|
|
21
|
+
* Report: the violation is gone
|
|
22
|
+
*
|
|
23
|
+
* Nobody had to claim a pass. They deleted the question instead. So this
|
|
24
|
+
* implementation refuses to delete questions. The worst an override can
|
|
25
|
+
* do is draw a labelled box around a real violation and sign it with a
|
|
26
|
+
* reason and a date — which leaves a reader BETTER informed than a plain
|
|
27
|
+
* failure would, not worse.
|
|
28
|
+
*
|
|
29
|
+
* Consequences enforced elsewhere, and deliberately not weakened:
|
|
30
|
+
* - the exit code still fails on an overridden violation (cli.ts), or
|
|
31
|
+
* CI becomes the loophole this whole design exists to close;
|
|
32
|
+
* - the report's headline verdict counts overridden failures, or the
|
|
33
|
+
* one line everyone reads would be the one line that lies.
|
|
34
|
+
*
|
|
35
|
+
* A reason is mandatory. An override without one is refused, not applied
|
|
36
|
+
* silently: it costs a sentence to write, and it is the part a reviewer
|
|
37
|
+
* actually reads.
|
|
38
|
+
*
|
|
39
|
+
* AMBIGUOUS IDS FAIL CLOSED. A global CLAUDE.md and a project one can
|
|
40
|
+
* legitimately both contain a "Rule 1" — the report already disambiguates
|
|
41
|
+
* those on collision. An override written as `"rule": "1"` when two rules
|
|
42
|
+
* share that id would silently disable BOTH, including one the user never
|
|
43
|
+
* meant to touch. Found while testing this feature against a real machine
|
|
44
|
+
* that has a global rules file. So a bare id is applied only when it is
|
|
45
|
+
* unambiguous; when it is not, the override is refused and the user is
|
|
46
|
+
* told to write `"project:1"` or `"global:1"` instead.
|
|
47
|
+
*/
|
|
48
|
+
export const OVERRIDES_FILE = ".rulereceipt.json";
|
|
49
|
+
/**
|
|
50
|
+
* Resolves override entries against the rules actually present, and
|
|
51
|
+
* refuses anything ambiguous rather than guessing which rule was meant.
|
|
52
|
+
* Returns a lookup keyed by `${source}:${id}`, plus any new problems.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveOverrides(loaded, targets) {
|
|
55
|
+
const bySourceAndId = new Map();
|
|
56
|
+
const problems = [...loaded.problems];
|
|
57
|
+
for (const [written, entry] of loaded.byRuleId) {
|
|
58
|
+
const scoped = /^(global|project):(.+)$/i.exec(written);
|
|
59
|
+
if (scoped) {
|
|
60
|
+
const source = scoped[1].toLowerCase();
|
|
61
|
+
const id = scoped[2].trim();
|
|
62
|
+
const hit = targets.find((t) => t.ruleId === id && t.ruleSource === source);
|
|
63
|
+
if (!hit) {
|
|
64
|
+
problems.push(`${OVERRIDES_FILE}: no ${source} rule with id "${id}" was found, so that override did nothing.`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
bySourceAndId.set(`${source}:${id}`, entry);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const matches = targets.filter((t) => t.ruleId === written);
|
|
71
|
+
if (matches.length === 0) {
|
|
72
|
+
problems.push(`${OVERRIDES_FILE}: no rule with id "${written}" was found, so that override did nothing.`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const sources = new Set(matches.map((m) => m.ruleSource));
|
|
76
|
+
if (sources.size > 1) {
|
|
77
|
+
problems.push(`${OVERRIDES_FILE}: rule id "${written}" exists in BOTH your global and project rules, so the override was NOT applied — it would have silently disabled both. Write "project:${written}" or "global:${written}" instead.`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
bySourceAndId.set(`${[...sources][0]}:${written}`, entry);
|
|
81
|
+
}
|
|
82
|
+
return { bySourceAndId, problems };
|
|
83
|
+
}
|
|
84
|
+
function isNonEmptyString(v) {
|
|
85
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Reads and validates the override file. Never throws: a broken override
|
|
89
|
+
* file must not take down a check that would otherwise have worked, and
|
|
90
|
+
* an unreadable file means "no overrides", never "override everything".
|
|
91
|
+
* Every rejection is reported rather than swallowed, so a user whose
|
|
92
|
+
* override isn't working finds out why.
|
|
93
|
+
*/
|
|
94
|
+
export function loadOverrides(cwd) {
|
|
95
|
+
const byRuleId = new Map();
|
|
96
|
+
const problems = [];
|
|
97
|
+
const path = join(cwd, OVERRIDES_FILE);
|
|
98
|
+
if (!existsSync(path))
|
|
99
|
+
return { byRuleId, problems };
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
problems.push(`${OVERRIDES_FILE} isn't valid JSON, so no overrides were applied: ${err instanceof Error ? err.message : String(err)}`);
|
|
106
|
+
return { byRuleId, problems };
|
|
107
|
+
}
|
|
108
|
+
const raw = parsed?.overrides;
|
|
109
|
+
if (raw === undefined)
|
|
110
|
+
return { byRuleId, problems };
|
|
111
|
+
if (!Array.isArray(raw)) {
|
|
112
|
+
problems.push(`${OVERRIDES_FILE}: "overrides" must be an array, so no overrides were applied.`);
|
|
113
|
+
return { byRuleId, problems };
|
|
114
|
+
}
|
|
115
|
+
for (const [i, entry] of raw.entries()) {
|
|
116
|
+
const e = entry;
|
|
117
|
+
if (!isNonEmptyString(e?.rule)) {
|
|
118
|
+
problems.push(`${OVERRIDES_FILE} entry ${i + 1}: missing a "rule" id, so it was ignored.`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Fails closed, on purpose. An override with no stated reason is the
|
|
122
|
+
// exact shape of one added to make a number go away.
|
|
123
|
+
if (!isNonEmptyString(e?.reason)) {
|
|
124
|
+
problems.push(`${OVERRIDES_FILE} entry for rule ${e.rule}: no "reason" given, so it was NOT applied. Every override needs a reason a reviewer can read.`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
byRuleId.set(e.rule.trim(), {
|
|
128
|
+
rule: e.rule.trim(),
|
|
129
|
+
reason: e.reason.trim(),
|
|
130
|
+
date: isNonEmptyString(e.date) ? e.date.trim() : undefined,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { byRuleId, problems };
|
|
134
|
+
}
|
|
@@ -34,11 +34,18 @@ function stripControlChars(value) {
|
|
|
34
34
|
* directory layout — that is a leak in the one artifact most likely to
|
|
35
35
|
* leave the machine.
|
|
36
36
|
*
|
|
37
|
-
* This is
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
37
|
+
* This is DISPLAY FORMATTING, not a safety mechanism, and the difference
|
|
38
|
+
* matters. It is a fixed, deterministic substitution of one known string
|
|
39
|
+
* — always correct, never guessing — and it makes paths easier to read as
|
|
40
|
+
* a side benefit. It protects nothing.
|
|
41
|
+
*
|
|
42
|
+
* Auto-detecting "sensitive" content and scrubbing it would be a losing
|
|
43
|
+
* game: it can never catch everything, and a partial scrub is worse than
|
|
44
|
+
* none because it invites the user to trust the output. Rule text and
|
|
45
|
+
* evidence are reproduced verbatim on purpose, because that is what makes
|
|
46
|
+
* the report worth sending. The real safeguard is the warning the CLI
|
|
47
|
+
* prints at write time, which puts the responsibility where it belongs —
|
|
48
|
+
* with the person who knows what is in their own rules file.
|
|
42
49
|
*/
|
|
43
50
|
function redactHome(value) {
|
|
44
51
|
const home = homedir();
|
|
@@ -96,6 +103,13 @@ function renderResultRow(result, all) {
|
|
|
96
103
|
</div>
|
|
97
104
|
<h3 class="result__title">${clean(result.ruleTitle)}</h3>
|
|
98
105
|
${result.evidence ? `<p class="result__evidence">${clean(result.evidence)}</p>` : ""}
|
|
106
|
+
${result.overriddenReason
|
|
107
|
+
? `<div class="override">
|
|
108
|
+
<strong>Marked by the developer as “not a rule for this project”${result.overriddenDate ? ` on ${clean(result.overriddenDate)}` : ""}.</strong>
|
|
109
|
+
Reason given: ${clean(result.overriddenReason)}
|
|
110
|
+
<span class="override__truth">Without this override, the result is: <strong>${clean(BUCKET_LABEL[bucketOf({ ...result, overriddenReason: undefined })])}</strong></span>
|
|
111
|
+
</div>`
|
|
112
|
+
: ""}
|
|
99
113
|
</article>`;
|
|
100
114
|
}
|
|
101
115
|
function renderSection(bucket, results, all) {
|
|
@@ -195,6 +209,9 @@ export function generateHtmlReport(results, meta) {
|
|
|
195
209
|
.result--judgment { border-left-color: #6b6f76; }
|
|
196
210
|
.badge--judgment { background: #f2f3f5; color: #4a4e55; }
|
|
197
211
|
.section__note { font-size: 13px; color: var(--muted); margin: -4px 0 12px; }
|
|
212
|
+
.override { margin-top: 10px; padding: 10px 12px; border-radius: 6px; background: var(--unclear-bg); border: 1px solid var(--unclear-line); font-size: 13.5px; color: var(--ink-soft); }
|
|
213
|
+
.override strong { color: var(--ink); }
|
|
214
|
+
.override__truth { display: block; margin-top: 6px; }
|
|
198
215
|
.result__id { font-size: 12px; color: var(--muted); }
|
|
199
216
|
.result__title { font-size: 15px; margin: 0 0 6px; font-weight: 600; }
|
|
200
217
|
.result__evidence { margin: 0; font-size: 14px; color: var(--muted); white-space: pre-wrap; }
|
|
@@ -225,7 +242,7 @@ export function generateHtmlReport(results, meta) {
|
|
|
225
242
|
|
|
226
243
|
<div class="verdict verdict--${v.cls}">
|
|
227
244
|
<strong>${clean(v.text)}</strong>
|
|
228
|
-
<span>${countBy(results, "PASS")} followed · ${countBy(results, "FAIL")} not followed · ${results.filter((r) => bucketOf(r) === "UNCLEAR_EVIDENCE").length} couldn't tell · ${results.filter((r) => bucketOf(r) === "UNCLEAR_JUDGMENT").length} need your judgment</span>
|
|
245
|
+
<span>${countBy(results, "PASS")} followed · ${countBy(results, "FAIL")} not followed · ${results.filter((r) => bucketOf(r) === "UNCLEAR_EVIDENCE").length} couldn't tell · ${results.filter((r) => bucketOf(r) === "UNCLEAR_JUDGMENT").length} need your judgment${results.filter((r) => r.overriddenReason).length > 0 ? ` · ${results.filter((r) => r.overriddenReason).length} developer-overridden` : ""}</span>
|
|
229
246
|
</div>
|
|
230
247
|
|
|
231
248
|
<table class="facts">
|
|
@@ -64,11 +64,21 @@ function summaryLine(results) {
|
|
|
64
64
|
const fail = results.filter((r) => r.status === "FAIL").length;
|
|
65
65
|
const needsHuman = results.filter((r) => r.status === "UNCLEAR" && r.needsHuman).length;
|
|
66
66
|
const couldntTell = results.filter((r) => r.status === "UNCLEAR" && !r.needsHuman).length;
|
|
67
|
+
const overriddenFails = results.filter((r) => r.status === "FAIL" && r.overriddenReason).length;
|
|
68
|
+
const overridden = results.filter((r) => r.overriddenReason).length;
|
|
67
69
|
const parts = [`${pass} followed`, `${fail} not followed`];
|
|
68
70
|
if (couldntTell > 0)
|
|
69
71
|
parts.push(`${couldntTell} couldn't tell`);
|
|
70
72
|
if (needsHuman > 0)
|
|
71
73
|
parts.push(`${needsHuman} need your judgment`);
|
|
74
|
+
// Named in the one line everyone reads. A headline that quietly folded
|
|
75
|
+
// overridden failures into a clean total would be the single most
|
|
76
|
+
// misleading thing this tool could print.
|
|
77
|
+
if (overridden > 0) {
|
|
78
|
+
parts.push(overriddenFails > 0
|
|
79
|
+
? `${overridden} user-overridden (${overriddenFails} still not followed)`
|
|
80
|
+
: `${overridden} user-overridden`);
|
|
81
|
+
}
|
|
72
82
|
return parts.join(" · ");
|
|
73
83
|
}
|
|
74
84
|
export function generateReport(results, meta) {
|
|
@@ -80,6 +90,15 @@ export function generateReport(results, meta) {
|
|
|
80
90
|
lines.push(`${MARK[r.status]} ${r.status.padEnd(7)} ${ruleLabel(r, clean)}`);
|
|
81
91
|
if (r.evidence)
|
|
82
92
|
lines.push(` evidence: ${r.evidence}`);
|
|
93
|
+
// The true status is printed above, unchanged. This line adds the
|
|
94
|
+
// override on top of it rather than replacing it — a reader must be
|
|
95
|
+
// able to see what the result would have been without the override.
|
|
96
|
+
if (r.overriddenReason) {
|
|
97
|
+
lines.push(` USER-OVERRIDDEN as "not a rule for this project"${r.overriddenDate ? ` on ${r.overriddenDate}` : ""} — reason: ${r.overriddenReason}`);
|
|
98
|
+
if (r.status === "FAIL") {
|
|
99
|
+
lines.push(" NOTE: this rule was NOT FOLLOWED. The override does not change that.");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
83
102
|
}
|
|
84
103
|
lines.push("─".repeat(40));
|
|
85
104
|
lines.push(summaryLine(clean));
|
package/dist/types.d.ts
CHANGED
|
@@ -32,6 +32,15 @@ export interface CheckResult {
|
|
|
32
32
|
ruleSource: "global" | "project";
|
|
33
33
|
status: CheckStatus;
|
|
34
34
|
evidence: string;
|
|
35
|
+
/**
|
|
36
|
+
* Set when the user marked this rule as "not a rule for my project" in
|
|
37
|
+
* .rulereceipt.json. The status above is STILL the real, computed
|
|
38
|
+
* result — an override changes presentation only, never the answer.
|
|
39
|
+
* See src/overrides.ts for why that distinction is the whole design.
|
|
40
|
+
*/
|
|
41
|
+
overriddenReason?: string;
|
|
42
|
+
/** Optional date from the override entry, so a reader can judge staleness. */
|
|
43
|
+
overriddenDate?: string;
|
|
35
44
|
/**
|
|
36
45
|
* True when this rule was never mechanically answerable — a judgment
|
|
37
46
|
* call like "surface bad news first", which has no command to inspect.
|