@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
|
@@ -134,6 +134,20 @@ collect_project_rules() {
|
|
|
134
134
|
-not -path '*/node_modules/*' 2>/dev/null | sort)
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
# Hard rules must come from a tree the PR author does not control: a PR that
|
|
138
|
+
# deletes a rule and then breaks it would otherwise leave nothing to enforce.
|
|
139
|
+
collect_project_rules_at_ref() {
|
|
140
|
+
local repo="$1" ref="$2" dir status=0
|
|
141
|
+
dir="$(mktemp -d "/tmp/lekker-base-rules-XXXXXX")"
|
|
142
|
+
if git -C "$repo" archive "$ref" 2>/dev/null | tar -x -C "$dir" 2>/dev/null; then
|
|
143
|
+
collect_project_rules "$dir"
|
|
144
|
+
else
|
|
145
|
+
status=1
|
|
146
|
+
fi
|
|
147
|
+
rm -rf "$dir"
|
|
148
|
+
return "$status"
|
|
149
|
+
}
|
|
150
|
+
|
|
137
151
|
# Scan depth needs PR-authored review guidance but skips expensive worktree
|
|
138
152
|
# setup. Read the PR tree from an archive so no Git worktree or checkout is made.
|
|
139
153
|
if [[ "${LEKKER_RULES_ONLY:-}" == "1" ]]; then
|
|
@@ -143,20 +157,57 @@ if [[ "${LEKKER_RULES_ONLY:-}" == "1" ]]; then
|
|
|
143
157
|
if git -C "$REPO_ROOT" fetch origin "+refs/heads/${PR_BRANCH}:refs/remotes/origin/${PR_BRANCH}" 2>/dev/null; then
|
|
144
158
|
RULES_REF="origin/$PR_BRANCH"
|
|
145
159
|
elif git -C "$REPO_ROOT" fetch origin "$PR_BRANCH" 2>/dev/null; then
|
|
146
|
-
|
|
160
|
+
# The base fetch below overwrites FETCH_HEAD, so pin the PR tip now.
|
|
161
|
+
RULES_REF="$(git -C "$REPO_ROOT" rev-parse FETCH_HEAD)"
|
|
147
162
|
else
|
|
148
163
|
printf 'ERROR: could not fetch PR branch %s for rules-only scan\n' "$PR_BRANCH" >&2
|
|
149
164
|
exit 1
|
|
150
165
|
fi
|
|
151
166
|
fi
|
|
167
|
+
# Base rules are best effort: a failure here keeps projectRules and nulls baseProjectRules,
|
|
168
|
+
# because the base copy only hardens validation and must not abort the scan.
|
|
169
|
+
RULES_BASE_SHA=""
|
|
170
|
+
if [[ -n "${LEKKER_BASE_REF:-}" ]]; then
|
|
171
|
+
BASE_FETCH_OK=1
|
|
172
|
+
if [[ -z "$LOCAL_REPO" && "$LEKKER_BASE_REF" == origin/* ]]; then
|
|
173
|
+
BASE_BRANCH="${LEKKER_BASE_REF#origin/}"
|
|
174
|
+
git -C "$REPO_ROOT" fetch origin \
|
|
175
|
+
"+refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}" 2>/dev/null || BASE_FETCH_OK=0
|
|
176
|
+
fi
|
|
177
|
+
# Fork point, as in full mode: the base tip may carry rules added after the PR branched.
|
|
178
|
+
if [[ "$BASE_FETCH_OK" == "1" ]] \
|
|
179
|
+
&& git -C "$REPO_ROOT" rev-parse --verify -q "${LEKKER_BASE_REF}^{commit}" >/dev/null; then
|
|
180
|
+
RULES_BASE_SHA="$(git -C "$REPO_ROOT" merge-base "$LEKKER_BASE_REF" "$RULES_REF" 2>/dev/null || true)"
|
|
181
|
+
# A fork older than the shallow clone depth has no common ancestor until history is deepened.
|
|
182
|
+
if [[ -z "$RULES_BASE_SHA" ]] \
|
|
183
|
+
&& [[ "$(git -C "$REPO_ROOT" rev-parse --is-shallow-repository 2>/dev/null)" == "true" ]]; then
|
|
184
|
+
git -C "$REPO_ROOT" fetch -q --deepen=200 origin 2>/dev/null || true
|
|
185
|
+
RULES_BASE_SHA="$(git -C "$REPO_ROOT" merge-base "$LEKKER_BASE_REF" "$RULES_REF" 2>/dev/null || true)"
|
|
186
|
+
fi
|
|
187
|
+
fi
|
|
188
|
+
fi
|
|
152
189
|
RULES_SOURCE="/tmp/lekker-rules-source-$$"
|
|
153
190
|
mkdir -p "$RULES_SOURCE"
|
|
154
191
|
git -C "$REPO_ROOT" archive "$RULES_REF" | tar -x -C "$RULES_SOURCE"
|
|
155
192
|
PROJECT_RULES="$(collect_project_rules "$RULES_SOURCE")"
|
|
156
|
-
|
|
193
|
+
BASE_PROJECT_RULES=""
|
|
194
|
+
HAVE_BASE_RULES=0
|
|
195
|
+
if [[ -n "${LEKKER_BASE_REF:-}" ]]; then
|
|
196
|
+
if [[ -n "$RULES_BASE_SHA" ]] \
|
|
197
|
+
&& BASE_PROJECT_RULES="$(collect_project_rules_at_ref "$REPO_ROOT" "$RULES_BASE_SHA")"; then
|
|
198
|
+
HAVE_BASE_RULES=1
|
|
199
|
+
else
|
|
200
|
+
BASE_PROJECT_RULES=""
|
|
201
|
+
NOTES="${NOTES}base-rules-unavailable;"
|
|
202
|
+
fi
|
|
203
|
+
fi
|
|
204
|
+
python3 - "$OUT_JSON" "$PROJECT_RULES" "$BASE_PROJECT_RULES" "$HAVE_BASE_RULES" "$NOTES" <<'PYEOF'
|
|
157
205
|
import json, sys
|
|
206
|
+
out = {"projectRules": sys.argv[2]}
|
|
207
|
+
out["baseProjectRules"] = sys.argv[3] if sys.argv[4] == "1" else None
|
|
208
|
+
out["notes"] = sys.argv[5]
|
|
158
209
|
with open(sys.argv[1], "w") as f:
|
|
159
|
-
json.dump(
|
|
210
|
+
json.dump(out, f, indent=2)
|
|
160
211
|
PYEOF
|
|
161
212
|
rm -rf "$RULES_SOURCE"
|
|
162
213
|
printf 'OK %s\n' "$OUT_JSON"
|
|
@@ -475,6 +526,20 @@ PROJECT_RULES_FILE="/tmp/lekker-rules-$$.txt"
|
|
|
475
526
|
printf '' > "$PROJECT_RULES_FILE"
|
|
476
527
|
collect_project_rules "$WORKTREE_PATH" > "$PROJECT_RULES_FILE"
|
|
477
528
|
|
|
529
|
+
BASE_RULES_FILE="/tmp/lekker-base-rules-$$.txt"
|
|
530
|
+
BASE_RULES_STATE="absent"
|
|
531
|
+
printf '' > "$BASE_RULES_FILE"
|
|
532
|
+
if [[ -n "$MERGE_BASE_NOTE" && "$MERGE_BASE_NOTE" != "unknown" ]] \
|
|
533
|
+
&& git -C "$REPO_ROOT" rev-parse --verify -q "${MERGE_BASE_NOTE}^{commit}" >/dev/null; then
|
|
534
|
+
if collect_project_rules_at_ref "$REPO_ROOT" "$MERGE_BASE_NOTE" > "$BASE_RULES_FILE"; then
|
|
535
|
+
BASE_RULES_STATE="present"
|
|
536
|
+
else
|
|
537
|
+
NOTES="${NOTES}base-rules-unavailable;"
|
|
538
|
+
fi
|
|
539
|
+
else
|
|
540
|
+
NOTES="${NOTES}base-rules-unavailable;"
|
|
541
|
+
fi
|
|
542
|
+
|
|
478
543
|
# ---------------------------------------------------------------------------
|
|
479
544
|
# 6. HEAD SHA
|
|
480
545
|
# ---------------------------------------------------------------------------
|
|
@@ -518,14 +583,17 @@ python3 - \
|
|
|
518
583
|
"$DEPS_INSTALL_FILE" \
|
|
519
584
|
"$ESLINT_JSON_FILE" \
|
|
520
585
|
"$TEST_RUNNER_JSON" \
|
|
521
|
-
"$OUT_JSON"
|
|
586
|
+
"$OUT_JSON" \
|
|
587
|
+
"$BASE_RULES_FILE" \
|
|
588
|
+
"$BASE_RULES_STATE" <<'PYEOF'
|
|
522
589
|
import json, sys, os
|
|
523
590
|
|
|
524
591
|
(worktree_path, repo_root, head_sha, head_sha_short,
|
|
525
592
|
tsc_file, eslint_file, rules_file, delta_file, notes,
|
|
526
593
|
changed_files_file, base_ref_val, eslint_scope_file,
|
|
527
594
|
tsc_changed_file, tsc_count_file, merge_base_val,
|
|
528
|
-
deps_install_file, eslint_json_file, test_runner_json, out_json
|
|
595
|
+
deps_install_file, eslint_json_file, test_runner_json, out_json,
|
|
596
|
+
base_rules_file, base_rules_state) = sys.argv[1:22]
|
|
529
597
|
|
|
530
598
|
def parse_runner(raw):
|
|
531
599
|
try:
|
|
@@ -581,6 +649,7 @@ data = {
|
|
|
581
649
|
"tscTail": read_file(tsc_file),
|
|
582
650
|
"eslintTail": read_file(eslint_file),
|
|
583
651
|
"projectRules": read_file(rules_file),
|
|
652
|
+
"baseProjectRules": read_file(base_rules_file) if base_rules_state == "present" else None,
|
|
584
653
|
"deltaFile": None if delta_file == "null" else delta_file,
|
|
585
654
|
"notes": notes,
|
|
586
655
|
"changedFiles": changed_files,
|
|
@@ -598,7 +667,7 @@ data = {
|
|
|
598
667
|
"eslintJson": eslint_json_file if file_has_content(eslint_json_file) else None,
|
|
599
668
|
}
|
|
600
669
|
|
|
601
|
-
for p in (tsc_file, eslint_file, rules_file, changed_files_file,
|
|
670
|
+
for p in (tsc_file, eslint_file, rules_file, base_rules_file, changed_files_file,
|
|
602
671
|
eslint_scope_file, tsc_changed_file, tsc_count_file,
|
|
603
672
|
deps_install_file):
|
|
604
673
|
try:
|
|
@@ -75,6 +75,8 @@ export function loadDedup(workflowPath = join(SKILL, 'workflow.js')) {
|
|
|
75
75
|
liftConst('HARD_RULES', 'const HARD_RULES = new Set()'),
|
|
76
76
|
liftConst('SAME_ISSUE_LINE_WINDOW', 'const SAME_ISSUE_LINE_WINDOW = 30'),
|
|
77
77
|
liftConst('SEVERITY_RANK', 'const SEVERITY_RANK = { critical: 3, important: 2, observation: 1, idiomatic: 0 }'),
|
|
78
|
+
// Absent from an older workflow.js, whose sameIssue does not call it.
|
|
79
|
+
(() => { try { return lift('normalizeRuleQuote') } catch { return '' } })(),
|
|
78
80
|
...['titleTokens', 'sameIssue', 'nearbyLines', 'spanWithinWindow',
|
|
79
81
|
'isHardRule', 'longest', 'mergeFindings', 'dedup'].map(lift),
|
|
80
82
|
].join('\n')
|
package/package.json
CHANGED
|
@@ -469,12 +469,13 @@ Fire both tracks in the same turn.
|
|
|
469
469
|
At scan depth, collect review guidance without creating a worktree:
|
|
470
470
|
|
|
471
471
|
```bash
|
|
472
|
-
LEKKER_RULES_ONLY=1
|
|
472
|
+
LEKKER_RULES_ONLY=1 LEKKER_BASE_REF=origin/<BASE_BRANCH> \
|
|
473
|
+
${SKILL_ROOT}/scripts/setup-worktree.sh \
|
|
473
474
|
<REPO_SLUG> <PR_BRANCH> <scratchpad>/rules.json
|
|
474
475
|
```
|
|
475
476
|
|
|
476
477
|
Read `projectRules` from `rules.json` into `context.json`. This archive-only
|
|
477
|
-
mode reads the PR
|
|
478
|
+
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.
|
|
478
479
|
|
|
479
480
|
Run via Bash with `run_in_background`:
|
|
480
481
|
|
|
@@ -686,6 +687,7 @@ Write `<scratchpad>/context.json` with keys:
|
|
|
686
687
|
{
|
|
687
688
|
"acList": "<numbered ACs from your issue tracker, or empty>",
|
|
688
689
|
"projectRules": "<worktree.json or rules.json projectRules + any recalled review patterns appended under '## Recalled patterns'>",
|
|
690
|
+
"baseProjectRules": "<worktree.json or rules.json baseProjectRules, copied unchanged (no recalled patterns); null when absent>",
|
|
689
691
|
"changedPaths": "<PR files[].path list, or an exact matching worktree.json changedFiles list>",
|
|
690
692
|
"sentrySignals": "<monitoring issue summaries, or null>",
|
|
691
693
|
"ciStatus": "<passing | failing: <names> | pending | N/A>",
|
|
@@ -914,6 +916,13 @@ value first). `REVIEW.md` ignore rules never suppress findings in changed paths.
|
|
|
914
916
|
applies exclusions to agent and lint findings using `changedPaths`; if paths are
|
|
915
917
|
unavailable, keep findings. It never switches off a hard rule from `CLAUDE.md` or the house rules.
|
|
916
918
|
|
|
919
|
+
**Project rules are hard rules.** A prohibition or obligation in `projectRules`
|
|
920
|
+
(NEVER, MUST, ALWAYS, DO NOT or plain-English equivalent) that a changed line
|
|
921
|
+
violates is raised as Critical with `rule: "PROJECT:<slug>"` and a verbatim
|
|
922
|
+
`ruleQuote`. The verifier runs `scripts/project-rule-findings.mjs`, which drops
|
|
923
|
+
a finding whose quote is not found in `projectRules`; otherwise it takes the
|
|
924
|
+
hard-rule path (anchor and applicability, no runtime challenges).
|
|
925
|
+
|
|
917
926
|
**Approval standard.** The verdict answers "does this definitely improve the
|
|
918
927
|
codebase's health", not "is this how I would have written it". Perfect code does
|
|
919
928
|
not exist. `✅ LGTM - ship it` is the right call for a change that improves
|
|
@@ -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).
|
|
@@ -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).
|
|
@@ -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).
|
|
@@ -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
|
+
}
|