@olegkoval/agent-skills 1.70.0 → 1.71.1
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/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +11 -2
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/conventions.md +3 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/implementation.md +3 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/quality.md +3 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/simplification.md +3 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/test-quality.md +3 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/verifier.md +34 -3
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/fixtures/project-rules/CLAUDE.md +4 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/fixtures/project-rules/planted.diff +9 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/project-rule-findings.mjs +167 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +251 -3
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +75 -6
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/validate-lines.mjs +2 -0
- package/package.json +1 -1
- package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +11 -2
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/conventions.md +3 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/implementation.md +3 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/quality.md +3 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/simplification.md +3 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/test-quality.md +3 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/verifier.md +34 -3
- package/plugins/olko-github-pr/skills/lekker-review/scripts/fixtures/project-rules/CLAUDE.md +4 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/fixtures/project-rules/planted.diff +9 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/project-rule-findings.mjs +167 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +251 -3
- package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +75 -6
- package/plugins/olko-github-pr/skills/lekker-review/scripts/validate-lines.mjs +2 -0
- package/plugins/olko-github-pr/skills/lekker-review/workflow.js +80 -2
- package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
|
@@ -470,12 +470,13 @@ Fire both tracks in the same turn.
|
|
|
470
470
|
At scan depth, collect review guidance without creating a worktree:
|
|
471
471
|
|
|
472
472
|
```bash
|
|
473
|
-
LEKKER_RULES_ONLY=1
|
|
473
|
+
LEKKER_RULES_ONLY=1 LEKKER_BASE_REF=origin/<BASE_BRANCH> \
|
|
474
|
+
${SKILL_ROOT}/scripts/setup-worktree.sh \
|
|
474
475
|
<REPO_SLUG> <PR_BRANCH> <scratchpad>/rules.json
|
|
475
476
|
```
|
|
476
477
|
|
|
477
478
|
Read `projectRules` from `rules.json` into `context.json`. This archive-only
|
|
478
|
-
mode reads the PR
|
|
479
|
+
mode fetches the PR base and reads `baseProjectRules` at the merge base with the PR branch (as full mode does), not at the base tip. If the base cannot be fetched, resolved or merge-based, `baseProjectRules` is `null` and `notes` carries `base-rules-unavailable`; the scan still succeeds. It does not run static checks.
|
|
479
480
|
|
|
480
481
|
Run via Bash with `run_in_background`:
|
|
481
482
|
|
|
@@ -687,6 +688,7 @@ Write `<scratchpad>/context.json` with keys:
|
|
|
687
688
|
{
|
|
688
689
|
"acList": "<numbered ACs from your issue tracker, or empty>",
|
|
689
690
|
"projectRules": "<worktree.json or rules.json projectRules + any recalled review patterns appended under '## Recalled patterns'>",
|
|
691
|
+
"baseProjectRules": "<worktree.json or rules.json baseProjectRules, copied unchanged (no recalled patterns); null when absent>",
|
|
690
692
|
"changedPaths": "<PR files[].path list, or an exact matching worktree.json changedFiles list>",
|
|
691
693
|
"sentrySignals": "<monitoring issue summaries, or null>",
|
|
692
694
|
"ciStatus": "<passing | failing: <names> | pending | N/A>",
|
|
@@ -915,6 +917,13 @@ value first). `REVIEW.md` ignore rules never suppress findings in changed paths.
|
|
|
915
917
|
applies exclusions to agent and lint findings using `changedPaths`; if paths are
|
|
916
918
|
unavailable, keep findings. It never switches off a hard rule from `CLAUDE.md` or the house rules.
|
|
917
919
|
|
|
920
|
+
**Project rules are hard rules.** A prohibition or obligation in `projectRules`
|
|
921
|
+
(NEVER, MUST, ALWAYS, DO NOT or plain-English equivalent) that a changed line
|
|
922
|
+
violates is raised as Critical with `rule: "PROJECT:<slug>"` and a verbatim
|
|
923
|
+
`ruleQuote`. The verifier runs `scripts/project-rule-findings.mjs`, which drops
|
|
924
|
+
a finding whose quote is not found in `projectRules`; otherwise it takes the
|
|
925
|
+
hard-rule path (anchor and applicability, no runtime challenges).
|
|
926
|
+
|
|
918
927
|
**Approval standard.** The verdict answers "does this definitely improve the
|
|
919
928
|
codebase's health", not "is this how I would have written it". Perfect code does
|
|
920
929
|
not exist. `✅ LGTM - ship it` is the right call for a change that improves
|
package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/conventions.md
CHANGED
|
@@ -127,6 +127,9 @@ Use it as a prior, not as a checklist:
|
|
|
127
127
|
EXISTING_REVIEWS: read key "existingReviews" from CONTEXT_FILE (awareness only, skip findings already raised)
|
|
128
128
|
|
|
129
129
|
PROJECT_RULES to verify: read key "projectRules" from CONTEXT_FILE.
|
|
130
|
+
A changed line that violates a prohibition or obligation in PROJECT_RULES is a Critical
|
|
131
|
+
finding tagged `rule: "PROJECT:<slug>"` with `ruleQuote` set to the exact rule sentence
|
|
132
|
+
(see PROJECT RULES AS HARD RULES in the task message).
|
|
130
133
|
|
|
131
134
|
Diff: read the full unified PR diff from the file DIFF_FILE (absolute path given in your task message). Do NOT run gh pr diff.
|
|
132
135
|
Worktree: WORKTREE_PATH is given in your task message (null in scan mode, diff only).
|
package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/implementation.md
CHANGED
|
@@ -105,6 +105,9 @@ SENTRY_SIGNALS: read key "sentrySignals" from CONTEXT_FILE.
|
|
|
105
105
|
EXISTING_REVIEWS: read key "existingReviews" from CONTEXT_FILE (awareness only, skip findings already raised)
|
|
106
106
|
|
|
107
107
|
PROJECT_RULES to verify: read key "projectRules" from CONTEXT_FILE.
|
|
108
|
+
A changed line that violates a prohibition or obligation in PROJECT_RULES is a Critical
|
|
109
|
+
finding tagged `rule: "PROJECT:<slug>"` with `ruleQuote` set to the exact rule sentence
|
|
110
|
+
(see PROJECT RULES AS HARD RULES in the task message).
|
|
108
111
|
|
|
109
112
|
Diff: read the full unified PR diff from the file DIFF_FILE (absolute path given in your task message). Do NOT run gh pr diff.
|
|
110
113
|
Worktree: WORKTREE_PATH is given in your task message (null in scan mode, diff only).
|
|
@@ -72,6 +72,9 @@ Note: awareness only. Do not anchor on prior reviews. Skip findings already
|
|
|
72
72
|
raised at the same file:line by another reviewer.
|
|
73
73
|
|
|
74
74
|
PROJECT_RULES to verify: read key "projectRules" from CONTEXT_FILE.
|
|
75
|
+
A changed line that violates a prohibition or obligation in PROJECT_RULES is a Critical
|
|
76
|
+
finding tagged `rule: "PROJECT:<slug>"` with `ruleQuote` set to the exact rule sentence
|
|
77
|
+
(see PROJECT RULES AS HARD RULES in the task message).
|
|
75
78
|
|
|
76
79
|
Diff: read the full unified PR diff from the file DIFF_FILE (absolute path given in your task message). Do NOT run gh pr diff.
|
|
77
80
|
Worktree: WORKTREE_PATH is given in your task message (null in scan mode, diff only).
|
package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/simplification.md
CHANGED
|
@@ -56,6 +56,9 @@ actionable: name the move or drop the finding.
|
|
|
56
56
|
EXISTING_REVIEWS: read key "existingReviews" from CONTEXT_FILE (awareness only, skip findings already raised)
|
|
57
57
|
|
|
58
58
|
PROJECT_RULES to verify: read key "projectRules" from CONTEXT_FILE.
|
|
59
|
+
A changed line that violates a prohibition or obligation in PROJECT_RULES is a Critical
|
|
60
|
+
finding tagged `rule: "PROJECT:<slug>"` with `ruleQuote` set to the exact rule sentence
|
|
61
|
+
(see PROJECT RULES AS HARD RULES in the task message).
|
|
59
62
|
|
|
60
63
|
Diff: read the full unified PR diff from the file DIFF_FILE (absolute path given in your task message). Do NOT run gh pr diff.
|
|
61
64
|
Worktree: WORKTREE_PATH is given in your task message (null in scan mode, diff only).
|
package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/test-quality.md
CHANGED
|
@@ -169,6 +169,9 @@ Return the review summary through these structured fields in addition to
|
|
|
169
169
|
EXISTING_REVIEWS: read key "existingReviews" from CONTEXT_FILE (awareness only, skip findings already raised)
|
|
170
170
|
|
|
171
171
|
PROJECT_RULES to verify: read key "projectRules" from CONTEXT_FILE.
|
|
172
|
+
A changed line that violates a prohibition or obligation in PROJECT_RULES is a Critical
|
|
173
|
+
finding tagged `rule: "PROJECT:<slug>"` with `ruleQuote` set to the exact rule sentence
|
|
174
|
+
(see PROJECT RULES AS HARD RULES in the task message).
|
|
172
175
|
Diff: read DIFF_FILE. List every test file/spec
|
|
173
176
|
Worktree: WORKTREE_PATH is given in your task message (null in scan mode, diff only).
|
|
174
177
|
|
|
@@ -28,14 +28,34 @@ unless Step 0 validates it through the house hard-rule path.
|
|
|
28
28
|
|
|
29
29
|
## Step 0 -- Hard-rule validation (check this FIRST)
|
|
30
30
|
|
|
31
|
-
If the FINDING JSON has any non-empty `rule` field,
|
|
32
|
-
such as `TS-1`, `TS-2`, `GQL-1`, or `PR-1`, do NOT run the five adversarial
|
|
31
|
+
If the FINDING JSON has any non-empty `rule` field, do NOT run the five adversarial
|
|
33
32
|
challenges below. They ask
|
|
34
33
|
runtime-failure questions that a standards violation can never answer, and
|
|
35
34
|
answering them honestly would drop a finding that house-rules policy declares
|
|
36
35
|
Critical on standards grounds rather than on runtime behaviour.
|
|
37
36
|
|
|
38
|
-
|
|
37
|
+
For `rule: "weakens-project-rule"`, use this dedicated path:
|
|
38
|
+
|
|
39
|
+
1. **Removed-line anchor**: confirm the quoted rule appears on a `-` line in
|
|
40
|
+
`DIFF_FILE` for the finding's file. Ignore `---` file headers. This anchor is
|
|
41
|
+
valid for both a deletion and a replacement; do not require a matching `+`
|
|
42
|
+
line or downgrade the finding because it has no production-code `+` line.
|
|
43
|
+
2. **Base-rule validation**: write the FINDING JSON to a unique temp file and run
|
|
44
|
+
`node <this prompt's directory>/../../scripts/project-rule-findings.mjs <finding.json> <CONTEXT_FILE>`.
|
|
45
|
+
The validator requires the exact quote to exist in `baseProjectRules`; a
|
|
46
|
+
missing base copy or rejected quote fails this check. Do not validate against
|
|
47
|
+
`projectRules`, which may already reflect the PR's weakened rules. The validator also
|
|
48
|
+
rejects a rule line that is still present unchanged in `projectRules` (the rule line was neither removed nor changed).
|
|
49
|
+
Exit 1 means `dropped`. Exit 2 means the validator could not run (for example
|
|
50
|
+
`baseProjectRules` is missing): check by hand that the exact quote appears in
|
|
51
|
+
`baseProjectRules` ONLY, never in `projectRules`; if `baseProjectRules` is not a string
|
|
52
|
+
or the quote is absent there, `dropped`. Otherwise keep the finding, say in `reasoning`
|
|
53
|
+
that the validator was unavailable, and set `validatorUnavailable: true`.
|
|
54
|
+
|
|
55
|
+
Both checks pass -> `confirmed` at the finding's Important severity. Either
|
|
56
|
+
check fails -> `dropped`. Do not downgrade a confirmed weakening.
|
|
57
|
+
|
|
58
|
+
For all other non-empty `rule` tags, run exactly two checks:
|
|
39
59
|
|
|
40
60
|
1. **Anchor**: the flagged code genuinely appears on a `+` line of
|
|
41
61
|
`DIFF_FILE` (for PR-1, the PR title genuinely lacks a ticket present in
|
|
@@ -55,6 +75,17 @@ the rule and quoting the violating snippet. Either check fails -> `dropped`,
|
|
|
55
75
|
with `reasoning` naming which check failed. There is no `downgraded` outcome
|
|
56
76
|
for a hard rule: it either violates the rule or it does not.
|
|
57
77
|
|
|
78
|
+
For a `rule` starting with `PROJECT:`, the rule text lives in `baseProjectRules` of
|
|
79
|
+
`CONTEXT_FILE` when that key is a string (the base branch's copy, which the PR cannot
|
|
80
|
+
edit), otherwise in `projectRules`, not in `HOUSE_RULES_FILE`. Write the FINDING JSON to a unique temp file
|
|
81
|
+
(create it with `mktemp`, never a fixed name, because verifiers run in parallel) and run `node <this prompt's directory>/../../scripts/project-rule-findings.mjs <finding.json> <CONTEXT_FILE>`.
|
|
82
|
+
Exit 1 means the quoted rule is not in `projectRules` (or the tag is malformed):
|
|
83
|
+
`dropped`, with that output as `reasoning`. Any other non-zero exit means the validator
|
|
84
|
+
could not run: read that same key yourself and confirm the exact quoted sentence is in it.
|
|
85
|
+
If it is not, `dropped`. If it is, keep the finding at its severity, say in `reasoning` that
|
|
86
|
+
the validator was unavailable, set `validatorUnavailable: true`, and judge applicability as below. On exit 0, judge applicability
|
|
87
|
+
against the quoted rule as the second check.
|
|
88
|
+
|
|
58
89
|
Then **stop** -- do not continue to Step 1 or the five challenges.
|
|
59
90
|
|
|
60
91
|
---
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Validates findings tagged `rule: "PROJECT:<slug>"`.
|
|
3
|
+
//
|
|
4
|
+
// A project's own prohibitions and obligations (CLAUDE.md, AGENTS.md, REVIEW.md)
|
|
5
|
+
// are collected into `projectRules`. A lens that raises one as a hard rule must
|
|
6
|
+
// quote the rule it enforces. A quote that is not in `projectRules` means the
|
|
7
|
+
// model invented the rule, so the finding is rejected instead of being allowed
|
|
8
|
+
// to ride the hard-rule path past the runtime challenges.
|
|
9
|
+
//
|
|
10
|
+
// node project-rule-findings.mjs <finding.json> <context.json>
|
|
11
|
+
//
|
|
12
|
+
// Exit 0 when valid or when the finding is not a PROJECT rule, 1 when rejected,
|
|
13
|
+
// 2 when the validator itself could not run (usage error, unreadable or malformed input).
|
|
14
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const TAG_PATTERN = /^PROJECT:[a-z0-9]+(-[a-z0-9]+)*$/
|
|
18
|
+
const MIN_QUOTE_LENGTH = 12
|
|
19
|
+
|
|
20
|
+
export function isProjectRuleTag(tag) {
|
|
21
|
+
return typeof tag === 'string' && TAG_PATTERN.test(tag)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Case, runs of whitespace and markdown emphasis or list markers vary between a
|
|
25
|
+
// rules file and a model's copy of it; the words must not.
|
|
26
|
+
export function normalizeQuote(text) {
|
|
27
|
+
return String(text ?? '')
|
|
28
|
+
.replace(/(^|\n)\s{0,3}#{1,6}\s+/g, '$1')
|
|
29
|
+
.replace(/(^|\n)\s{0,3}>\s?/g, '$1')
|
|
30
|
+
.replace(/`([^`\n]+)`/g, '$1')
|
|
31
|
+
.replace(/(^|[\s([{])(\*{1,2}|_{1,2})(\S(?:.*?\S)?)\2(?=$|[\s.,;:!?)}\]])/g, '$1$3')
|
|
32
|
+
.replace(/^\s*[-+]\s+/gm, ' ')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const RECALLED_PATTERNS_HEADING = /^\s{0,3}#{1,6}\s+recalled patterns\s*$/gim
|
|
39
|
+
|
|
40
|
+
// Recalled patterns are memory, not the project's rules, so nothing below the last such heading is a valid quote source.
|
|
41
|
+
// SKILL.md appends that heading at the end; an earlier one inside a rules file must not drop the rules after it.
|
|
42
|
+
export function stripRecalledPatterns(text) {
|
|
43
|
+
const source = String(text ?? '')
|
|
44
|
+
const last = [...source.matchAll(RECALLED_PATTERNS_HEADING)].at(-1)
|
|
45
|
+
return last ? source.slice(0, last.index) : source
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function quoteAppearsIn(quote, projectRules) {
|
|
49
|
+
const needle = normalizeQuote(quote)
|
|
50
|
+
if (!needle) return false
|
|
51
|
+
const rules = stripRecalledPatterns(projectRules)
|
|
52
|
+
if (needle.length >= MIN_QUOTE_LENGTH && normalizeQuote(rules).includes(needle)) return true
|
|
53
|
+
return rules.split(/\r?\n/).some(line => normalizeQuote(line) === needle)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateProjectRuleFinding(finding, projectRules) {
|
|
57
|
+
const tag = finding?.rule
|
|
58
|
+
if (typeof tag !== 'string' || !tag.startsWith('PROJECT:')) {
|
|
59
|
+
return { ok: true, reason: 'not a PROJECT rule' }
|
|
60
|
+
}
|
|
61
|
+
if (!isProjectRuleTag(tag)) {
|
|
62
|
+
return { ok: false, reason: `malformed tag "${tag}": expected PROJECT:<kebab-case-slug>` }
|
|
63
|
+
}
|
|
64
|
+
if (typeof finding.ruleQuote !== 'string' || finding.ruleQuote.trim() === '') {
|
|
65
|
+
return { ok: false, reason: 'missing ruleQuote: quote the project rule verbatim' }
|
|
66
|
+
}
|
|
67
|
+
if (!quoteAppearsIn(finding.ruleQuote, projectRules)) {
|
|
68
|
+
return { ok: false, reason: 'ruleQuote not found in projectRules' }
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, reason: 'quote found in projectRules' }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Still present means the whole rule block is unchanged; any wording change in the block
|
|
74
|
+
// counts as possibly weakened and is left to the verifier.
|
|
75
|
+
const LIST_MARKER = /^\s*([-+*]|\d+[.)])\s+/
|
|
76
|
+
|
|
77
|
+
const indentOf = line => line.length - line.trimStart().length
|
|
78
|
+
|
|
79
|
+
// A deeper marker or indented text belongs to its parent bullet, even after a blank line (loose list),
|
|
80
|
+
// so an exception there must change the parent block.
|
|
81
|
+
const isHeading = line => /^\s{0,3}#{1,6}\s/.test(line)
|
|
82
|
+
const isListItem = line => LIST_MARKER.test(line)
|
|
83
|
+
|
|
84
|
+
function startsNewBlock(line, current) {
|
|
85
|
+
if (isHeading(line)) return true
|
|
86
|
+
const [first] = current
|
|
87
|
+
if (first === undefined) return true
|
|
88
|
+
if (current[current.length - 1] === '') return !(isListItem(first) && indentOf(line) > indentOf(first))
|
|
89
|
+
if (!isListItem(line)) return false
|
|
90
|
+
if (isListItem(first)) return indentOf(line) <= indentOf(first)
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ruleBlocks(text) {
|
|
95
|
+
const groups = stripRecalledPatterns(text).split(/\r?\n/).reduce((acc, line) => {
|
|
96
|
+
const current = acc[acc.length - 1]
|
|
97
|
+
if (line.trim() === '') return current === undefined || current.length === 0 ? acc : [...acc.slice(0, -1), [...current, '']]
|
|
98
|
+
if (current === undefined || startsNewBlock(line, current)) return [...acc, [line]]
|
|
99
|
+
return [...acc.slice(0, -1), [...current, line]]
|
|
100
|
+
}, [])
|
|
101
|
+
return groups
|
|
102
|
+
.map(lines => lines.map((line, i) => normalizeQuote(i === 0 ? line.replace(LIST_MARKER, '') : line)).filter(line => line !== '').join(' '))
|
|
103
|
+
.filter(block => block !== '')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function ruleLineStillPresent(quote, baseProjectRules, projectRules) {
|
|
107
|
+
const needle = normalizeQuote(quote)
|
|
108
|
+
const baseHits = needle ? ruleBlocks(baseProjectRules).filter(block => block.includes(needle)) : []
|
|
109
|
+
if (baseHits.length === 0) return false
|
|
110
|
+
const prBlocks = new Set(ruleBlocks(projectRules))
|
|
111
|
+
return baseHits.some(block => prBlocks.has(block))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// `unavailable` marks a missing input, not a judgement on the finding: the CLI maps it to exit 2.
|
|
115
|
+
export function validateWeakenedProjectRuleFinding(finding, baseProjectRules, projectRules) {
|
|
116
|
+
if (finding?.rule !== 'weakens-project-rule') {
|
|
117
|
+
return { ok: true, reason: 'not a weakened project rule' }
|
|
118
|
+
}
|
|
119
|
+
if (typeof baseProjectRules !== 'string') {
|
|
120
|
+
return { ok: false, unavailable: true, reason: 'baseProjectRules unavailable' }
|
|
121
|
+
}
|
|
122
|
+
if (typeof finding.ruleQuote !== 'string' || finding.ruleQuote.trim() === '') {
|
|
123
|
+
return { ok: false, reason: 'missing ruleQuote: quote the removed project rule' }
|
|
124
|
+
}
|
|
125
|
+
if (!quoteAppearsIn(finding.ruleQuote, baseProjectRules)) {
|
|
126
|
+
return { ok: false, reason: 'ruleQuote not found in baseProjectRules' }
|
|
127
|
+
}
|
|
128
|
+
if (typeof projectRules === 'string' && ruleLineStillPresent(finding.ruleQuote, baseProjectRules, projectRules)) {
|
|
129
|
+
return { ok: false, reason: 'rule still present in projectRules' }
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, reason: 'quote found in baseProjectRules' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function main(argv) {
|
|
135
|
+
const [findingPath, contextPath] = argv
|
|
136
|
+
if (!findingPath || !contextPath) {
|
|
137
|
+
console.error('usage: project-rule-findings.mjs <finding.json> <context.json>')
|
|
138
|
+
return 2
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const finding = JSON.parse(readFileSync(findingPath, 'utf8'))
|
|
142
|
+
const context = JSON.parse(readFileSync(contextPath, 'utf8'))
|
|
143
|
+
if (finding?.rule === 'weakens-project-rule') {
|
|
144
|
+
const result = validateWeakenedProjectRuleFinding(finding, context.baseProjectRules, context.projectRules)
|
|
145
|
+
console.log(JSON.stringify(result))
|
|
146
|
+
if (result.unavailable) return 2
|
|
147
|
+
return result.ok ? 0 : 1
|
|
148
|
+
}
|
|
149
|
+
// Base-branch rules are authoritative; PR-tree rules only when no base was resolved.
|
|
150
|
+
const authoritative = typeof context.baseProjectRules === 'string' ? context.baseProjectRules : context.projectRules
|
|
151
|
+
const result = validateProjectRuleFinding(finding, authoritative)
|
|
152
|
+
console.log(JSON.stringify(result))
|
|
153
|
+
return result.ok ? 0 : 1
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// Exit 1 is reserved for "rejected"; a tooling failure must not read as one.
|
|
156
|
+
console.error(`project-rule-findings: unavailable: ${error.message}`)
|
|
157
|
+
return 2
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isMain() {
|
|
162
|
+
try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (isMain()) {
|
|
166
|
+
process.exit(main(process.argv.slice(2)))
|
|
167
|
+
}
|
|
@@ -52,11 +52,13 @@ const preamble = [
|
|
|
52
52
|
"const SEVERITY_RANK = { observation: 0, idiomatic: 1, important: 2, critical: 3 }",
|
|
53
53
|
// hardRuleCorroborated is likewise gone from the current file and kept in this
|
|
54
54
|
// list for the same reason: an older workflow.js calls it from isHardRule.
|
|
55
|
-
...['titleTokens', 'sameIssue', 'nearbyLines', 'spanWithinWindow', 'hardRuleCorroborated',
|
|
55
|
+
...['titleTokens', 'normalizeRuleQuote', 'weakenedRuleSeverity', 'sameIssue', 'nearbyLines', 'spanWithinWindow', 'hardRuleCorroborated',
|
|
56
56
|
'isHardRule', 'longest', 'mergeFindings', 'dedup', 'shouldVerify', 'isProveAttempt', 'countProveAttempt'].map(n => {
|
|
57
57
|
// countProveAttempt is called directly by the checks, so its stub returns NaN and the
|
|
58
58
|
// checks FAIL with a diff instead of the run crashing on an older workflow.js.
|
|
59
59
|
try { return lift(n) } catch {
|
|
60
|
+
if (n === 'normalizeRuleQuote') return "function normalizeRuleQuote() { return 'absent from this workflow.js' }"
|
|
61
|
+
if (n === 'weakenedRuleSeverity') return "function weakenedRuleSeverity() { return 'absent from this workflow.js' }"
|
|
60
62
|
return n === 'countProveAttempt'
|
|
61
63
|
? 'function countProveAttempt() { return NaN }'
|
|
62
64
|
: `function ${n}() { throw new Error('${n} absent from this workflow.js') }`
|
|
@@ -64,8 +66,8 @@ const preamble = [
|
|
|
64
66
|
}),
|
|
65
67
|
].join('\n')
|
|
66
68
|
|
|
67
|
-
const { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt } =
|
|
68
|
-
new Function(preamble + '\nreturn { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt }')()
|
|
69
|
+
const { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt, normalizeRuleQuote, weakenedRuleSeverity } =
|
|
70
|
+
new Function(preamble + '\nreturn { dedup, isHardRule, shouldVerify, sameIssue, isProveAttempt, countProveAttempt, normalizeRuleQuote, weakenedRuleSeverity }')()
|
|
69
71
|
|
|
70
72
|
let failed = 0
|
|
71
73
|
function check(name, actual, expected) {
|
|
@@ -696,5 +698,251 @@ check('ledger: proven prover adds one', countProveAttempt(2, { attempted: true,
|
|
|
696
698
|
const bareWrites = src.split('\n').filter(l => /proveAttemptCount\s*(\+\+|\+=|=[^=])/.test(l) && !/countProveAttempt\(|let proveAttemptCount/.test(l))
|
|
697
699
|
check('the attempt count is written only through countProveAttempt', bareWrites, [])
|
|
698
700
|
|
|
701
|
+
console.log('\nproject rules: PROJECT:<slug> findings must quote a rule that exists')
|
|
702
|
+
const { spawnSync: spawnPr } = await import('node:child_process')
|
|
703
|
+
const { mkdtempSync: mkPr, cpSync: cpPr, readFileSync: rdPr, rmSync: rmPr, symlinkSync: syPr, writeFileSync: wfPr } = await import('node:fs')
|
|
704
|
+
const { tmpdir: tmpPr } = await import('node:os')
|
|
705
|
+
const { validateProjectRuleFinding, validateWeakenedProjectRuleFinding, isProjectRuleTag, normalizeQuote } =
|
|
706
|
+
await import('./project-rule-findings.mjs')
|
|
707
|
+
|
|
708
|
+
const fixtureDir = join(SKILL, 'scripts', 'fixtures', 'project-rules')
|
|
709
|
+
const prRepo = mkPr(join(tmpPr(), 'lekker-pr-'))
|
|
710
|
+
cpPr(join(fixtureDir, 'CLAUDE.md'), join(prRepo, 'CLAUDE.md'))
|
|
711
|
+
const prGit = (...a) => spawnPr('git', ['-C', prRepo, '-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { encoding: 'utf8' })
|
|
712
|
+
prGit('init', '-q', '-b', 'main'); prGit('add', '.'); prGit('commit', '-qm', 'rules')
|
|
713
|
+
const prOut = join(prRepo, 'rules.json')
|
|
714
|
+
const collected = spawnPr('bash', [join(SKILL, 'scripts', 'setup-worktree.sh'), 'o/r', 'main', prOut], {
|
|
715
|
+
encoding: 'utf8', env: { ...process.env, LEKKER_RULES_ONLY: '1', LEKKER_LOCAL_REPO: prRepo },
|
|
716
|
+
})
|
|
717
|
+
const planted = collected.status === 0 ? JSON.parse(rdPr(prOut, 'utf8')).projectRules : ''
|
|
718
|
+
check('rules collection exits 0 on the planted fixture', collected.status, 0)
|
|
719
|
+
check('projectRules carries both planted rules',
|
|
720
|
+
[planted.includes('NEVER call console.log in src/.'), planted.includes('NEVER mutate function arguments.')], [true, true])
|
|
721
|
+
rmPr(prRepo, { recursive: true, force: true })
|
|
722
|
+
|
|
723
|
+
const prFinding = (rule, ruleQuote) => ({ rule, ruleQuote, file: 'src/total.js', line: 2, title: 't' })
|
|
724
|
+
check('accepts PROJECT:no-console-log with a matching quote',
|
|
725
|
+
validateProjectRuleFinding(prFinding('PROJECT:no-console-log', 'NEVER call console.log in src/.'), planted).ok, true)
|
|
726
|
+
check('accepts a quote that differs only in case and spacing',
|
|
727
|
+
validateProjectRuleFinding(prFinding('PROJECT:no-mutation', 'never mutate function arguments'), planted).ok, true)
|
|
728
|
+
check('accepts exact short project rules',
|
|
729
|
+
validateProjectRuleFinding(prFinding('PROJECT:no-eval', 'NEVER eval.'), '- NEVER eval.').ok, true)
|
|
730
|
+
check('rejects altered identifiers in project rule quotes',
|
|
731
|
+
validateProjectRuleFinding(prFinding('PROJECT:no-unsafe', 'NEVER call unsafe().'), 'NEVER call `_unsafe()`.').ok, false)
|
|
732
|
+
check('rejects altered operators in project rule quotes',
|
|
733
|
+
validateProjectRuleFinding(prFinding('PROJECT:nonnegative', 'MUST validate n = 0.'), 'MUST validate n >= 0.').ok, false)
|
|
734
|
+
check('rejects a quote that is absent from projectRules',
|
|
735
|
+
validateProjectRuleFinding(prFinding('PROJECT:no-eval', 'NEVER call eval anywhere in the codebase'), planted).ok, false)
|
|
736
|
+
check('rejects a PROJECT tag with no quote',
|
|
737
|
+
validateProjectRuleFinding({ rule: 'PROJECT:no-console-log' }, planted).ok, false)
|
|
738
|
+
const weakenedFinding = prFinding('weakens-project-rule', 'NEVER call console.log in src/.')
|
|
739
|
+
check('accepts a weakened rule quoted from baseProjectRules',
|
|
740
|
+
validateWeakenedProjectRuleFinding(weakenedFinding, planted).ok, true)
|
|
741
|
+
check('rejects a weakened rule quote absent from baseProjectRules',
|
|
742
|
+
validateWeakenedProjectRuleFinding(weakenedFinding, 'NEVER call eval.').ok, false)
|
|
743
|
+
check('rejects a weakened rule when baseProjectRules is unavailable',
|
|
744
|
+
validateWeakenedProjectRuleFinding(weakenedFinding, undefined).ok, false)
|
|
745
|
+
check('rejects a weakened rule whose quote is still in projectRules',
|
|
746
|
+
validateWeakenedProjectRuleFinding(weakenedFinding, planted, planted).ok, false)
|
|
747
|
+
check('accepts a weakened rule whose quote is gone from projectRules',
|
|
748
|
+
validateWeakenedProjectRuleFinding(weakenedFinding, planted, 'nothing here').ok, true)
|
|
749
|
+
const evalBase = '- NEVER call eval in src'
|
|
750
|
+
const evalFinding = prFinding('weakens-project-rule', 'NEVER call eval in src')
|
|
751
|
+
check('accepts a weakening that appends an exception to the rule line',
|
|
752
|
+
validateWeakenedProjectRuleFinding(evalFinding, evalBase, '- NEVER call eval in src unless input is trusted').ok, true)
|
|
753
|
+
check('accepts a weakening that adds a period and an Exception sentence on the same line',
|
|
754
|
+
validateWeakenedProjectRuleFinding(evalFinding, evalBase, '- NEVER call eval in src. Exception: trusted input.').ok, true)
|
|
755
|
+
check('rejects a weakened rule whose line is unchanged among other lines',
|
|
756
|
+
validateWeakenedProjectRuleFinding(evalFinding, evalBase, '# Rules\n- NEVER call eval in src\n- Be kind').reason, 'rule still present in projectRules')
|
|
757
|
+
const wrappedBase = '- After ANY catalog change, run build and commit\n the result'
|
|
758
|
+
const wrappedFinding = prFinding('weakens-project-rule', 'After ANY catalog change, run build and commit the result')
|
|
759
|
+
check('accepts a weakening appended to the last line of a wrapped rule',
|
|
760
|
+
validateWeakenedProjectRuleFinding(wrappedFinding, wrappedBase, '- After ANY catalog change, run build and commit\n the result, except docs-only changes').ok, true)
|
|
761
|
+
check('rejects an unchanged wrapped rule',
|
|
762
|
+
validateWeakenedProjectRuleFinding(wrappedFinding, wrappedBase, wrappedBase).reason, 'rule still present in projectRules')
|
|
763
|
+
check('rejects a wrapped rule re-wrapped at a different column',
|
|
764
|
+
validateWeakenedProjectRuleFinding(wrappedFinding, wrappedBase, '- After ANY catalog change, run build\n and commit the result').reason, 'rule still present in projectRules')
|
|
765
|
+
check('rejects a rule whose bullet marker changed to an asterisk',
|
|
766
|
+
validateWeakenedProjectRuleFinding(evalFinding, evalBase, '* NEVER call eval in src').reason, 'rule still present in projectRules')
|
|
767
|
+
check('rejects an unchanged numbered rule',
|
|
768
|
+
validateWeakenedProjectRuleFinding(evalFinding, '1. NEVER call eval in src', '1. NEVER call eval in src').reason, 'rule still present in projectRules')
|
|
769
|
+
check('rejects a bullet rule renumbered in the PR',
|
|
770
|
+
validateWeakenedProjectRuleFinding(evalFinding, evalBase, '1. NEVER call eval in src').reason, 'rule still present in projectRules')
|
|
771
|
+
check('accepts a weakening that adds a prefix qualifier before the quote',
|
|
772
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER force-push'), '- NEVER force-push', '- Except on your own branch, NEVER force-push').ok, true)
|
|
773
|
+
check('accepts a weakening added on a new continuation line of the same bullet',
|
|
774
|
+
validateWeakenedProjectRuleFinding(wrappedFinding, wrappedBase + '.', wrappedBase + '.\n Docs-only changes are exempt.').ok, true)
|
|
775
|
+
check('rejects a single-line rule re-wrapped over two lines',
|
|
776
|
+
validateWeakenedProjectRuleFinding(wrappedFinding, '- After ANY catalog change, run build and commit the result', wrappedBase).reason, 'rule still present in projectRules')
|
|
777
|
+
check('rejects a rule moved to another section unchanged',
|
|
778
|
+
validateWeakenedProjectRuleFinding(evalFinding, '# A\n' + evalBase + '\n# B\n- x', '# B\n- x\n# A\n' + evalBase).reason, 'rule still present in projectRules')
|
|
779
|
+
check('accepts an exception appended to a two-line blockquote rule',
|
|
780
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER force-push to main or release branches.'), '> NEVER force-push to main\n> or release branches.', '> NEVER force-push to main\n> or release branches. Exception: your own branch.').ok, true)
|
|
781
|
+
check('accepts an exception added as an indented sub-bullet',
|
|
782
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER force-push'), '- NEVER force-push', '- NEVER force-push\n - except on your own branch').ok, true)
|
|
783
|
+
check('accepts a parent bullet narrowed above an unchanged sub-bullet',
|
|
784
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER call eval'), '- In src:\n - NEVER call eval', '- In src/legacy only:\n - NEVER call eval').ok, true)
|
|
785
|
+
check('accepts a quote spanning two base bullets when the second gains an exception',
|
|
786
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER force-push\n- NEVER delete main'), '- NEVER force-push\n- NEVER delete main', '- NEVER force-push\n- NEVER delete main unless asked').ok, true)
|
|
787
|
+
check('rejects an unchanged rule with an unchanged nested sub-bullet',
|
|
788
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER force-push'), '- NEVER force-push\n - on shared branches', '- NEVER force-push\n - on shared branches').reason, 'rule still present in projectRules')
|
|
789
|
+
check('accepts an exception sub-bullet added after a blank line in a loose list',
|
|
790
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER force-push'), '- NEVER force-push', '- NEVER force-push\n\n - except on your own branch').ok, true)
|
|
791
|
+
check('accepts an indented exception paragraph added after a blank line',
|
|
792
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER force-push'), '- NEVER force-push', '- NEVER force-push\n\n Except on your own branch.').ok, true)
|
|
793
|
+
const introBase = 'These apply everywhere.\n- In domain code:\n - NEVER mutate state'
|
|
794
|
+
check('accepts a narrowed parent bullet under an intro paragraph',
|
|
795
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER mutate state'), introBase, 'These apply everywhere.\n- In domain code, except scripts:\n - NEVER mutate state').ok, true)
|
|
796
|
+
check('accepts an exception sibling added under a parent bullet below an intro paragraph',
|
|
797
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER mutate state'), introBase, introBase + '\n - Mutation is fine in reducers').ok, true)
|
|
798
|
+
check('rejects an unchanged loose-list rule with an unchanged indented paragraph',
|
|
799
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER force-push'), '- NEVER force-push\n\n on shared branches', '- NEVER force-push\n\n on shared branches').reason, 'rule still present in projectRules')
|
|
800
|
+
check('rejects unchanged blank-separated sibling bullets when another bullet is edited',
|
|
801
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER force-push'), '- NEVER force-push\n\n- NEVER call eval', '- NEVER force-push\n\n- NEVER call eval in src').reason, 'rule still present in projectRules')
|
|
802
|
+
check('rejects an unchanged bullet under a lead-in when a sibling is edited',
|
|
803
|
+
validateWeakenedProjectRuleFinding(prFinding('weakens-project-rule', 'NEVER force-push'), 'Rules:\n- NEVER force-push\n- NEVER call eval', 'Rules:\n- NEVER force-push\n- NEVER call eval, except tests').reason, 'rule still present in projectRules')
|
|
804
|
+
check('rejects a weakened rule with no quote',
|
|
805
|
+
validateWeakenedProjectRuleFinding({ rule: 'weakens-project-rule' }, planted).ok, false)
|
|
806
|
+
const verifierInstructions = rdPr(join(SKILL, 'references', 'agents', 'verifier.md'), 'utf8')
|
|
807
|
+
check('verifier has a dedicated weakened-project-rule path',
|
|
808
|
+
verifierInstructions.includes('For `rule: "weakens-project-rule"`, use this dedicated path:'), true)
|
|
809
|
+
check('verifier accepts deleted and replacement-line anchors',
|
|
810
|
+
verifierInstructions.includes('valid for both a deletion and a replacement'), true)
|
|
811
|
+
check('verifier confirms valid weakened rules at Important severity',
|
|
812
|
+
verifierInstructions.includes("`confirmed` at the finding's Important severity"), true)
|
|
813
|
+
check('rejects a malformed slug', validateProjectRuleFinding(prFinding('PROJECT:No Console', 'NEVER call console.log in src/.'), planted).ok, false)
|
|
814
|
+
check('a built-in tag is left to the house-rule path', validateProjectRuleFinding({ rule: 'TS-1' }, planted).ok, true)
|
|
815
|
+
const withRecalled = 'NEVER eval anywhere in src.\n## Recalled patterns\nNEVER call console.log in tests.'
|
|
816
|
+
check('accepts a quote above the recalled patterns heading',
|
|
817
|
+
validateProjectRuleFinding(prFinding('PROJECT:no-eval', 'NEVER eval anywhere in src.'), withRecalled).ok, true)
|
|
818
|
+
check('rejects a quote that appears only under recalled patterns',
|
|
819
|
+
validateProjectRuleFinding(prFinding('PROJECT:no-console-log', 'NEVER call console.log in tests.'), withRecalled).ok, false)
|
|
820
|
+
const twoRecalled = 'NEVER eval anywhere in src.\n## Recalled patterns\nNEVER use var in src.\n## Recalled patterns\nNEVER call console.log in tests.'
|
|
821
|
+
check('accepts a quote between two recalled patterns headings',
|
|
822
|
+
validateProjectRuleFinding(prFinding('PROJECT:no-var', 'NEVER use var in src.'), twoRecalled).ok, true)
|
|
823
|
+
check('rejects a quote after the last recalled patterns heading when the heading appears twice',
|
|
824
|
+
validateProjectRuleFinding(prFinding('PROJECT:no-console-log', 'NEVER call console.log in tests.'), twoRecalled).ok, false)
|
|
825
|
+
console.log('\nworkflow: a house-rule finding and a PROJECT finding on one line are never one issue')
|
|
826
|
+
const houseF = { file: 'src/total.js', line: 2, title: 'Missing null check on total', rule: 'TS-1' }
|
|
827
|
+
const projectF = { file: 'src/total.js', line: 2, title: 'Missing null check on total', rule: 'PROJECT:no-null-total', ruleQuote: 'NEVER skip the null check on total.' }
|
|
828
|
+
check('a house-rule finding and a PROJECT finding with similar titles are not sameIssue', sameIssue(houseF, projectF), false)
|
|
829
|
+
check('isProjectRuleTag accepts a kebab slug and rejects an empty one',
|
|
830
|
+
[isProjectRuleTag('PROJECT:a-b'), isProjectRuleTag('PROJECT:')], [true, false])
|
|
831
|
+
check('a PROJECT tag is a hard rule', isHardRule({ rule: 'PROJECT:no-console-log' }), true)
|
|
832
|
+
const quoteA = { file: 'src/total.js', line: 2, title: 'Missing null check on total', rule: 'PROJECT:no-null-total', ruleQuote: 'NEVER skip the null check on total.' }
|
|
833
|
+
const quoteB = { ...quoteA, ruleQuote: 'ALWAYS validate total before use.' }
|
|
834
|
+
check('two different quotes of one PROJECT rule are not sameIssue', sameIssue(quoteA, quoteB), false)
|
|
835
|
+
check('the same quote of one PROJECT rule is still sameIssue', sameIssue(quoteA, { ...quoteA }), true)
|
|
836
|
+
check('the same slug with quotes equal after normalisation is sameIssue',
|
|
837
|
+
sameIssue(quoteA, { ...quoteA, ruleQuote: ' never SKIP the null check on total. ' }), true)
|
|
838
|
+
const slugB = { ...quoteA, rule: 'PROJECT:null-total-guard', ruleQuote: '**Never skip** the null check on total.' }
|
|
839
|
+
check('two PROJECT slugs with one quote modulo case, whitespace and emphasis are sameIssue', sameIssue(quoteA, slugB), true)
|
|
840
|
+
const [mergedSlugs] = dedup([{ ...quoteA, severity: 'important' }, { ...slugB, severity: 'important' }])
|
|
841
|
+
check('different-slug same-quote findings merge into one finding that keeps rule and quote',
|
|
842
|
+
[dedup([{ ...quoteA, severity: 'important' }, { ...slugB, severity: 'important' }]).length, mergedSlugs.rule, typeof mergedSlugs.ruleQuote],
|
|
843
|
+
[1, 'PROJECT:no-null-total', 'string'])
|
|
844
|
+
check('different-slug different-quote findings stay separate',
|
|
845
|
+
sameIssue(quoteA, { ...slugB, ruleQuote: 'ALWAYS validate total before use.' }), false)
|
|
846
|
+
const quoteSamples = ['NEVER call `console.log` in src/.', '> **Never** skip\n the check', '- Use _early_ returns', '## Heading rule text']
|
|
847
|
+
check('workflow normalizeRuleQuote agrees with the validator normalizeQuote',
|
|
848
|
+
quoteSamples.map(q => normalizeRuleQuote(q)), quoteSamples.map(q => normalizeQuote(q)))
|
|
849
|
+
check('quotes differing only by trailing punctuation are not sameIssue',
|
|
850
|
+
sameIssue(quoteA, { ...quoteA, ruleQuote: 'NEVER skip the null check on total' }), false)
|
|
851
|
+
console.log('\nworkflow: a confirmed weakened-rule finding is always Important')
|
|
852
|
+
check('critical from the lens becomes important for weakens-project-rule',
|
|
853
|
+
weakenedRuleSeverity({ rule: 'weakens-project-rule' }, 'critical'), 'important')
|
|
854
|
+
check('other rules keep the severity', weakenedRuleSeverity({ rule: 'TS-1' }, 'critical'), 'critical')
|
|
855
|
+
|
|
856
|
+
// Prompt guard: every lens that reads projectRules is told to raise the tag.
|
|
857
|
+
const lensFiles = ['quality', 'implementation', 'simplification', 'conventions', 'test-quality']
|
|
858
|
+
check('every lens file that reads projectRules carries the PROJECT instruction',
|
|
859
|
+
lensFiles.filter(n => {
|
|
860
|
+
const t = rdPr(join(SKILL, 'references', 'agents', `${n}.md`), 'utf8')
|
|
861
|
+
return t.includes('"projectRules"') && !t.includes('PROJECT:<slug>')
|
|
862
|
+
}), [])
|
|
863
|
+
// Behavioural: lift the real reviewPrompt and build prompts with it. Removing the
|
|
864
|
+
// append in reviewPrompt makes the listed-lens check fail.
|
|
865
|
+
function liftBlock(name) {
|
|
866
|
+
const m = new RegExp(`^const ${name} = [\\s\\S]*?\\n\\n`, 'm').exec(src)
|
|
867
|
+
if (!m) throw new Error(`selftest: const ${name} not found in workflow.js`)
|
|
868
|
+
return m[0]
|
|
869
|
+
}
|
|
870
|
+
// An older workflow.js lacks these constants; fall back so the checks FAIL instead of the suite crashing.
|
|
871
|
+
const liftBlockOr = name => { try { return liftBlock(name) } catch { return `const ${name} = ''\n` } }
|
|
872
|
+
const buildPrompt = new Function(
|
|
873
|
+
'dim', 'SUPPRESSED', 'prevSha',
|
|
874
|
+
[liftBlockOr('PROJECT_RULE_DIMENSIONS'), liftBlockOr('PROJECT_RULE_INSTRUCTION'), liftConst('BUG_CLASS_INSTRUCTION'),
|
|
875
|
+
`const targetMetadata = '{}', promptDir = 'p', repoSlug = 'o/r', prNumber = 1, prUrl = 'u',
|
|
876
|
+
diffFile = 'd', contextFile = 'c', wtDisplay = 'w'`,
|
|
877
|
+
lift('reviewPrompt'), 'return reviewPrompt(dim)'].join('\n'),
|
|
878
|
+
)
|
|
879
|
+
const promptFor = key => buildPrompt({ key, file: `${key}.md` }, [], null)
|
|
880
|
+
check('a listed lens prompt carries the PROJECT instruction',
|
|
881
|
+
lensFiles.map(n => promptFor(n).includes('"PROJECT:<short-kebab-slug>"')), lensFiles.map(() => true))
|
|
882
|
+
check('an unlisted lens prompt does not', promptFor('deps').includes('PROJECT:<short-kebab-slug>'), false)
|
|
883
|
+
|
|
884
|
+
console.log('\nproject rules: dedup keeps the tag and its quote together')
|
|
885
|
+
const { dedup: dedupPr } = new Function(preamble + '\nreturn { dedup }')()
|
|
886
|
+
const plainF = { file: 'src/a.js', line: 2, severity: 'important', title: 'console.log left in src', bugClass: 'console-log', description: 'x', badCode: 'console.log(1)', fix: 'f', otherInstances: [] }
|
|
887
|
+
const taggedF = { ...plainF, severity: 'critical', rule: 'PROJECT:no-console-log', ruleQuote: 'NEVER call console.log in src/.' }
|
|
888
|
+
const taggedWithoutQuote = { ...taggedF }
|
|
889
|
+
delete taggedWithoutQuote.ruleQuote
|
|
890
|
+
for (const [label, pair] of [['plain first', [plainF, taggedF]], ['tagged first', [taggedF, plainF]]]) {
|
|
891
|
+
const [m] = dedupPr(pair)
|
|
892
|
+
check(`merge keeps rule and ruleQuote (${label})`, [m.rule, m.ruleQuote], ['PROJECT:no-console-log', 'NEVER call console.log in src/.'])
|
|
893
|
+
}
|
|
894
|
+
const [mergedQuote] = dedupPr([taggedWithoutQuote, taggedF])
|
|
895
|
+
check('merge fills missing quote for the same project rule',
|
|
896
|
+
[mergedQuote.rule, mergedQuote.ruleQuote], ['PROJECT:no-console-log', 'NEVER call console.log in src/.'])
|
|
897
|
+
|
|
898
|
+
const strayQuote = { ...plainF, ruleQuote: 'stale quote that belongs to no rule' }
|
|
899
|
+
const [mergedStray] = dedupPr([strayQuote, taggedF])
|
|
900
|
+
check('a stray quote on an untagged side does not survive the merge',
|
|
901
|
+
[mergedStray.rule, mergedStray.ruleQuote], ['PROJECT:no-console-log', 'NEVER call console.log in src/.'])
|
|
902
|
+
const [mergedLate] = dedupPr([{ ...plainF, rule: 'PROJECT:no-console-log' }, taggedF])
|
|
903
|
+
check('a quote from a later finding fills a quote-less same rule', mergedLate.ruleQuote, 'NEVER call console.log in src/.')
|
|
904
|
+
const otherRule = { ...taggedF, rule: 'PROJECT:no-mutation', ruleQuote: 'NEVER mutate function arguments.' }
|
|
905
|
+
check('two different PROJECT rules stay separate through dedup', dedupPr([taggedF, otherRule]).length, 2)
|
|
906
|
+
const [mergedNoQuote] = dedupPr([strayQuote, { ...taggedF, ruleQuote: undefined }])
|
|
907
|
+
check('a tagged side without a quote leaves no stray quote behind', mergedNoQuote.ruleQuote, undefined)
|
|
908
|
+
|
|
909
|
+
console.log('\nproject rules: validator crash is exit 2, rejection is exit 1')
|
|
910
|
+
const crashDir = mkPr(join(tmpPr(), 'lekker-crash-'))
|
|
911
|
+
const okCtx = join(crashDir, 'ctx.json'), badJson = join(crashDir, 'bad.json'), fnd = join(crashDir, 'f.json')
|
|
912
|
+
wfPr(okCtx, JSON.stringify({ projectRules: 'NEVER call console.log in src/.' }))
|
|
913
|
+
wfPr(badJson, '{not json')
|
|
914
|
+
wfPr(fnd, JSON.stringify({ rule: 'PROJECT:no-console-log', ruleQuote: 'NEVER call console.log in src/.' }))
|
|
915
|
+
const runV = (f, c) => spawnPr('node', [join(SKILL, 'scripts', 'project-rule-findings.mjs'), f, c], { encoding: 'utf8' }).status
|
|
916
|
+
check('exit 0 on a valid finding', runV(fnd, okCtx), 0)
|
|
917
|
+
// A PR that deletes the rule must not make its own violation pass: base rules win.
|
|
918
|
+
const spoofCtx = join(crashDir, 'spoof.json')
|
|
919
|
+
wfPr(spoofCtx, JSON.stringify({ projectRules: 'nothing here', baseProjectRules: 'NEVER call console.log in src/.' }))
|
|
920
|
+
check('quote found in base rules is accepted even when the PR tree dropped it', runV(fnd, spoofCtx), 0)
|
|
921
|
+
const addedCtx = join(crashDir, 'added.json')
|
|
922
|
+
wfPr(addedCtx, JSON.stringify({ projectRules: 'NEVER call console.log in src/.', baseProjectRules: '' }))
|
|
923
|
+
check('a rule only the PR tree contains is rejected when base rules exist', runV(fnd, addedCtx), 1)
|
|
924
|
+
const weakenedFile = join(crashDir, 'weakened.json')
|
|
925
|
+
wfPr(weakenedFile, JSON.stringify(weakenedFinding))
|
|
926
|
+
check('validator accepts a weakened-rule quote found in baseProjectRules',
|
|
927
|
+
runV(weakenedFile, spoofCtx), 0)
|
|
928
|
+
check('validator rejects a weakened-rule quote found only in projectRules',
|
|
929
|
+
runV(weakenedFile, addedCtx), 1)
|
|
930
|
+
wfPr(join(crashDir, 'rej.json'), JSON.stringify({ rule: 'PROJECT:no-eval', ruleQuote: 'NEVER call eval anywhere in code' }))
|
|
931
|
+
const noBaseCtx = join(crashDir, 'nobase.json')
|
|
932
|
+
wfPr(noBaseCtx, JSON.stringify({ projectRules: 'nothing here' }))
|
|
933
|
+
check('exit 2 on a weakened finding when baseProjectRules is missing', runV(weakenedFile, noBaseCtx), 2)
|
|
934
|
+
const stillCtx = join(crashDir, 'still.json')
|
|
935
|
+
wfPr(stillCtx, JSON.stringify({ projectRules: 'NEVER call console.log in src/.', baseProjectRules: 'NEVER call console.log in src/.' }))
|
|
936
|
+
check('exit 1 on a weakened finding whose rule is still in projectRules', runV(weakenedFile, stillCtx), 1)
|
|
937
|
+
check('exit 1 on a rejected finding', runV(join(crashDir, 'rej.json'), okCtx), 1)
|
|
938
|
+
const symlinkValidator = join(crashDir, 'project-rule-findings.mjs')
|
|
939
|
+
syPr(join(SKILL, 'scripts', 'project-rule-findings.mjs'), symlinkValidator)
|
|
940
|
+
check('exit 1 on a rejected finding through a symlink',
|
|
941
|
+
spawnPr('node', [symlinkValidator, join(crashDir, 'rej.json'), okCtx], { encoding: 'utf8' }).status, 1)
|
|
942
|
+
check('exit 2 on a missing context file', runV(fnd, join(crashDir, 'nope.json')), 2)
|
|
943
|
+
check('exit 2 on a malformed context file', runV(fnd, badJson), 2)
|
|
944
|
+
check('exit 2 on a malformed finding file', runV(badJson, okCtx), 2)
|
|
945
|
+
rmPr(crashDir, { recursive: true, force: true })
|
|
946
|
+
|
|
699
947
|
console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
|
|
700
948
|
process.exit(failed === 0 ? 0 : 1)
|