@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
|
@@ -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)
|
|
@@ -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')
|
|
@@ -33,6 +33,7 @@ const FINDINGS_ARRAY_SCHEMA = {
|
|
|
33
33
|
anyOf: [
|
|
34
34
|
{ properties: { severity: { enum: ['observation', 'idiomatic'] } } },
|
|
35
35
|
{ required: ['boundary'] },
|
|
36
|
+
{ required: ['rule'], properties: { rule: { pattern: '^(PROJECT:[a-z0-9]+(-[a-z0-9]+)*|weakens-project-rule)$' } } },
|
|
36
37
|
],
|
|
37
38
|
properties: {
|
|
38
39
|
file: { type: 'string' },
|
|
@@ -44,6 +45,8 @@ const FINDINGS_ARRAY_SCHEMA = {
|
|
|
44
45
|
fix: { type: 'string' },
|
|
45
46
|
precedent: { type: 'string' },
|
|
46
47
|
rule: { type: 'string', minLength: 1, pattern: '\\S' },
|
|
48
|
+
// Verbatim project rule a PROJECT:<slug> finding enforces.
|
|
49
|
+
ruleQuote: { type: 'string' },
|
|
47
50
|
boundary: BOUNDARY_SCHEMA,
|
|
48
51
|
// Short kebab-case slug naming the class of bug ("missing-null-check").
|
|
49
52
|
bugClass: { type: 'string', minLength: 1, pattern: '^[a-z0-9]+(-[a-z0-9]+)*$' },
|
|
@@ -107,6 +110,8 @@ const VERDICT_SCHEMA = {
|
|
|
107
110
|
verdict: { enum: ['confirmed', 'downgraded', 'dropped'] },
|
|
108
111
|
newSeverity: { enum: ['important', 'observation'] },
|
|
109
112
|
reasoning: { type: 'string' },
|
|
113
|
+
// True when the PROJECT-rule validator could not run (exit other than 0 or 1).
|
|
114
|
+
validatorUnavailable: { type: 'boolean' },
|
|
110
115
|
},
|
|
111
116
|
}
|
|
112
117
|
|
|
@@ -161,7 +166,41 @@ function titleTokens(title) {
|
|
|
161
166
|
return words.filter(function(w) { return w.length > 2 })
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
// Same normalisation as normalizeQuote in scripts/project-rule-findings.mjs; workflow.js cannot import it.
|
|
170
|
+
// The selftest asserts the two agree.
|
|
171
|
+
function normalizeRuleQuote(text) {
|
|
172
|
+
return String(text == null ? '' : text)
|
|
173
|
+
.replace(/(^|\n)\s{0,3}#{1,6}\s+/g, '$1')
|
|
174
|
+
.replace(/(^|\n)\s{0,3}>\s?/g, '$1')
|
|
175
|
+
.replace(/`([^`\n]+)`/g, '$1')
|
|
176
|
+
.replace(/(^|[\s([{])(\*{1,2}|_{1,2})(\S(?:.*?\S)?)\2(?=$|[\s.,;:!?)}\]])/g, '$1$3')
|
|
177
|
+
.replace(/^\s*[-+]\s+/gm, ' ')
|
|
178
|
+
.replace(/\s+/g, ' ')
|
|
179
|
+
.trim()
|
|
180
|
+
.toLowerCase()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function weakenedRuleSeverity(finding, severity) {
|
|
184
|
+
return finding.rule === 'weakens-project-rule' ? 'important' : severity
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
function sameIssue(a, b) {
|
|
188
|
+
const qa = normalizeRuleQuote(a.ruleQuote)
|
|
189
|
+
const qb = normalizeRuleQuote(b.ruleQuote)
|
|
190
|
+
// Lens-generated slugs can differ even when they identify the same project rule.
|
|
191
|
+
const sameProjectQuote = typeof a.rule === 'string' && a.rule.startsWith('PROJECT:')
|
|
192
|
+
&& typeof b.rule === 'string' && b.rule.startsWith('PROJECT:')
|
|
193
|
+
&& qa !== '' && qa === qb
|
|
194
|
+
if (typeof a.rule === 'string' && typeof b.rule === 'string' && a.rule !== b.rule
|
|
195
|
+
&& (a.rule.startsWith('PROJECT:') || b.rule.startsWith('PROJECT:'))
|
|
196
|
+
&& !sameProjectQuote) {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
// Two quotes of one PROJECT rule that differ are two different rule passages, not one issue.
|
|
200
|
+
if (typeof a.rule === 'string' && a.rule === b.rule && a.rule.startsWith('PROJECT:')
|
|
201
|
+
&& qa !== '' && qb !== '' && qa !== qb) {
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
165
204
|
// Merging two classes would send one class's instances to the other's fix.
|
|
166
205
|
if (a.bugClass && b.bugClass && a.bugClass !== b.bugClass) {
|
|
167
206
|
return false
|
|
@@ -182,6 +221,22 @@ const BUG_CLASS_INSTRUCTION = `BUG CLASS: give every finding a "bugClass" (short
|
|
|
182
221
|
+ ` hit, search the whole diff for the same pattern and list every other place as`
|
|
183
222
|
+ ` "file:line" (empty array when there is none). Report the class, not just the first hit.`
|
|
184
223
|
|
|
224
|
+
const PROJECT_RULE_DIMENSIONS = ['quality', 'implementation', 'simplification', 'conventions', 'test-quality',
|
|
225
|
+
'triage-quality', 'triage-logic']
|
|
226
|
+
|
|
227
|
+
const PROJECT_RULE_INSTRUCTION = `PROJECT RULES AS HARD RULES: read "projectRules" from CONTEXT_FILE.`
|
|
228
|
+
+ ` When a changed line violates a project rule written as a prohibition or an obligation`
|
|
229
|
+
+ ` (NEVER, MUST, MUST NOT, ALWAYS, DO NOT, or a plain-English equivalent), raise a Critical`
|
|
230
|
+
+ ` finding with "rule" set to "PROJECT:<short-kebab-slug>", "ruleQuote" set to the exact`
|
|
231
|
+
+ ` sentence from the rules (use "baseProjectRules" when it is a string: it is the base`
|
|
232
|
+
+ ` branch's copy, which the PR author cannot edit; otherwise "projectRules"), and file:line`
|
|
233
|
+
+ ` on the violating line. When the diff deletes or weakens a rule line that is in`
|
|
234
|
+
+ ` baseProjectRules, raise an Important finding with rule "weakens-project-rule" quoting`
|
|
235
|
+
+ ` the removed line. One finding per rule,`
|
|
236
|
+
+ ` with every other violating site in "otherInstances". Never quote a rule that is not in`
|
|
237
|
+
+ ` the authoritative source ("baseProjectRules" when it is a string, otherwise "projectRules"). Preferences and suggestions are not hard rules: report them as ordinary`
|
|
238
|
+
+ ` findings. A REVIEW.md ignore entry narrows where you look, it never disables a rule.`
|
|
239
|
+
|
|
185
240
|
const BUG_CLASS_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
186
241
|
const INSTANCE_PATTERN = new RegExp(INSTANCE_PATTERN_SOURCE)
|
|
187
242
|
|
|
@@ -234,6 +289,14 @@ function mergeFindings(a, b) {
|
|
|
234
289
|
|
|
235
290
|
if (!merged.rule && b.rule) {
|
|
236
291
|
merged.rule = b.rule
|
|
292
|
+
if (Object.prototype.hasOwnProperty.call(b, 'ruleQuote')) {
|
|
293
|
+
merged.ruleQuote = b.ruleQuote
|
|
294
|
+
} else {
|
|
295
|
+
delete merged.ruleQuote
|
|
296
|
+
}
|
|
297
|
+
} else if (merged.rule && merged.rule === b.rule && !merged.ruleQuote
|
|
298
|
+
&& typeof b.ruleQuote === 'string' && b.ruleQuote.trim()) {
|
|
299
|
+
merged.ruleQuote = b.ruleQuote
|
|
237
300
|
}
|
|
238
301
|
|
|
239
302
|
const agreedBy = []
|
|
@@ -547,7 +610,7 @@ if (Number(cycle) >= RETHINK_CYCLE) {
|
|
|
547
610
|
recurringClasses: Array.isArray(recurringClasses) ? recurringClasses : [],
|
|
548
611
|
findings: [],
|
|
549
612
|
preExisting: [],
|
|
550
|
-
droppedCount: 0, downgradedCount: 0, hardRuleCount: 0, agentCount: 0,
|
|
613
|
+
droppedCount: 0, downgradedCount: 0, hardRuleCount: 0, validatorUnavailableCount: 0, agentCount: 0,
|
|
551
614
|
proveAttemptCount: 0, provenCount: 0, suppressedCount: 0,
|
|
552
615
|
outputTokens: 0, turnTokensTotal: budget.spent(),
|
|
553
616
|
engine: ENGINE,
|
|
@@ -562,6 +625,7 @@ const budgetAtStart = budget.spent()
|
|
|
562
625
|
let droppedCount = 0
|
|
563
626
|
let downgradedCount = 0
|
|
564
627
|
let hardRuleCount = 0
|
|
628
|
+
let validatorUnavailableCount = 0
|
|
565
629
|
let provenCount = 0
|
|
566
630
|
let suppressedCount = 0
|
|
567
631
|
let proveAttemptCount = 0
|
|
@@ -613,6 +677,10 @@ const budgetAtStart = budget.spent()
|
|
|
613
677
|
|
|
614
678
|
prompt += ' ' + BUG_CLASS_INSTRUCTION
|
|
615
679
|
|
|
680
|
+
if (PROJECT_RULE_DIMENSIONS.includes(dim.key)) {
|
|
681
|
+
prompt += ' ' + PROJECT_RULE_INSTRUCTION
|
|
682
|
+
}
|
|
683
|
+
|
|
616
684
|
// The deterministic lint pre-flight has already collected every rule the
|
|
617
685
|
// repo's own eslint config fires on this diff, and those are exactly the
|
|
618
686
|
// comments the bot reviewers bill for. An agent re-reporting them costs a
|
|
@@ -650,7 +718,7 @@ const budgetAtStart = budget.spent()
|
|
|
650
718
|
`FINDING (JSON): ${JSON.stringify(finding)}.`,
|
|
651
719
|
`DIFF_FILE=${diffFile}, CONTEXT_FILE=${contextFile}, WORKTREE_PATH=${wtDisplay}.`,
|
|
652
720
|
`HOUSE_RULES_FILE: read key "houseRulesFile" from CONTEXT_FILE, then read that exact path before validating a hard rule.`,
|
|
653
|
-
`Default to dropping when uncertain.`,
|
|
721
|
+
`Default to dropping when uncertain, except when the PROJECT-rule validator was unavailable: then check by hand that the exact quote appears in baseProjectRules when it is a string, otherwise projectRules (drop if it does not), keep the finding only if it does, set validatorUnavailable true, and judge applicability. For rule "weakens-project-rule" check baseProjectRules ONLY: drop it when baseProjectRules is not a string or the quote is absent there, and never consult projectRules.`,
|
|
654
722
|
].join(' ')
|
|
655
723
|
}
|
|
656
724
|
|
|
@@ -708,6 +776,9 @@ const budgetAtStart = budget.spent()
|
|
|
708
776
|
// A model verdict cannot replace the required production boundary map.
|
|
709
777
|
function enforceBoundary(finding) {
|
|
710
778
|
if (!shouldVerify(finding, depth)) return finding
|
|
779
|
+
// A project rule or a weakened project rule is violated by the line itself; it needs no production entry point.
|
|
780
|
+
if (typeof finding.rule === 'string'
|
|
781
|
+
&& (finding.rule.startsWith('PROJECT:') || finding.rule === 'weakens-project-rule')) return finding
|
|
711
782
|
const boundary = finding.boundary
|
|
712
783
|
const hasText = function(value) {
|
|
713
784
|
return typeof value === 'string' && value.trim().length > 0
|
|
@@ -764,6 +835,11 @@ const budgetAtStart = budget.spent()
|
|
|
764
835
|
return kept
|
|
765
836
|
}
|
|
766
837
|
|
|
838
|
+
if (verdict.validatorUnavailable === true) {
|
|
839
|
+
validatorUnavailableCount++
|
|
840
|
+
log(`PROJECT-rule validator unavailable for ${finding.file}:${finding.line}; ${verdict.verdict === 'dropped' ? 'finding dropped' : 'finding kept'}`)
|
|
841
|
+
}
|
|
842
|
+
|
|
767
843
|
if (verdict.verdict === 'dropped') {
|
|
768
844
|
droppedCount++
|
|
769
845
|
return null
|
|
@@ -780,6 +856,7 @@ const budgetAtStart = budget.spent()
|
|
|
780
856
|
|
|
781
857
|
// confirmed
|
|
782
858
|
const confirmed = Object.assign({}, finding)
|
|
859
|
+
confirmed.severity = weakenedRuleSeverity(finding, confirmed.severity)
|
|
783
860
|
confirmed.verificationStatus = isHardRule(finding) ? 'hard-rule-confirmed' : 'confirmed'
|
|
784
861
|
confirmed.verifierReasoning = verdict.reasoning
|
|
785
862
|
return confirmed
|
|
@@ -1087,6 +1164,7 @@ const budgetAtStart = budget.spent()
|
|
|
1087
1164
|
droppedCount,
|
|
1088
1165
|
downgradedCount,
|
|
1089
1166
|
hardRuleCount,
|
|
1167
|
+
validatorUnavailableCount,
|
|
1090
1168
|
agentCount,
|
|
1091
1169
|
proveAttemptCount,
|
|
1092
1170
|
provenCount,
|