@olegkoval/agent-skills 1.76.0 → 1.77.0
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 +3 -1
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/codex-usage.mjs +69 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/git-evidence.mjs +39 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-adapter.mjs +1 -26
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-normalization.mjs +27 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +5 -4
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +51 -6
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/validate-lint-receipt.mjs +33 -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 +3 -1
- package/plugins/olko-github-pr/skills/lekker-review/scripts/codex-usage.mjs +69 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/git-evidence.mjs +39 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-adapter.mjs +1 -26
- package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-normalization.mjs +27 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +5 -4
- package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +51 -6
- package/plugins/olko-github-pr/skills/lekker-review/scripts/validate-lint-receipt.mjs +33 -0
- 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
|
@@ -511,9 +511,11 @@ ${SKILL_ROOT}/scripts/setup-worktree.sh \
|
|
|
511
511
|
On completion, read `worktree.json`. Keys emitted:
|
|
512
512
|
`worktreePath`, `repoRoot`, `headSha`, `headShaShort`, `tscTail`,
|
|
513
513
|
`tscChangedTail`, `tscErrorCount`, `eslintTail`, `eslintScope`, `eslintJson`,
|
|
514
|
-
`changedFiles`, `baseRef`, `mergeBase`, `projectRules`, `deltaFile`, `notes`,
|
|
514
|
+
`eslintProvenance`, `changedFiles`, `baseRef`, `mergeBase`, `projectRules`, `deltaFile`, `notes`,
|
|
515
515
|
`depsInstall`, `testRunner`.
|
|
516
516
|
|
|
517
|
+
`eslintProvenance` is the provenance-only receipt for `eslintJson` (base/head, tracked-worktree state, exit code, raw report hash).
|
|
518
|
+
|
|
517
519
|
`testRunner` is `{runner, dirs, reason}` from `scripts/test-runner.mjs`:
|
|
518
520
|
`runner` is `vitest|jest|mocha|node-test` only when its binary is installed
|
|
519
521
|
in the worktree, `dirs` lists `{dir, runner}` pairs for each usable package
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createReadStream, readFileSync } from 'node:fs';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const FIELDS = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens'];
|
|
7
|
+
const record = v => v && typeof v === 'object' && !Array.isArray(v);
|
|
8
|
+
const unavailable = (agentPath, reason) => ({ agentPath, status: 'unavailable', reason });
|
|
9
|
+
|
|
10
|
+
async function inspectSession(sessionFile, parentThreadId, agentPath) {
|
|
11
|
+
let sessionId = null, usage = null, complete = false, models = [];
|
|
12
|
+
try {
|
|
13
|
+
const input = createReadStream(sessionFile);
|
|
14
|
+
for await (const line of createInterface({ input, crlfDelay: Infinity })) {
|
|
15
|
+
if (!line.trim()) continue;
|
|
16
|
+
let row;
|
|
17
|
+
try { row = JSON.parse(line); } catch { return unavailable(agentPath, 'malformed JSONL'); }
|
|
18
|
+
if (row.type === 'session_meta') {
|
|
19
|
+
const spawn = row.payload?.source?.subagent?.thread_spawn;
|
|
20
|
+
if (sessionId || typeof row.payload?.id !== 'string' || !row.payload.id) return unavailable(agentPath, 'session metadata missing or duplicated');
|
|
21
|
+
if (spawn?.parent_thread_id !== parentThreadId || spawn?.agent_path !== agentPath) return unavailable(agentPath, 'parent thread or agent path mismatch');
|
|
22
|
+
sessionId = row.payload.id;
|
|
23
|
+
} else if (row.type === 'turn_context' && typeof row.payload?.model === 'string' && row.payload.model && !models.includes(row.payload.model)) {
|
|
24
|
+
models = [...models, row.payload.model];
|
|
25
|
+
} else if (row.type === 'event_msg' && row.payload?.type === 'task_started') complete = false;
|
|
26
|
+
else if (row.type === 'event_msg' && row.payload?.type === 'task_complete') complete = true;
|
|
27
|
+
else if (row.type === 'event_msg' && row.payload?.type === 'token_count') {
|
|
28
|
+
const raw = row.payload.info?.total_token_usage;
|
|
29
|
+
if (!record(raw) || FIELDS.some(k => !Number.isSafeInteger(raw[k]) || raw[k] < 0)) return unavailable(agentPath, 'token counters are malformed');
|
|
30
|
+
const next = Object.fromEntries(FIELDS.map(k => [k, raw[k]]));
|
|
31
|
+
if (usage && FIELDS.some(k => next[k] < usage[k])) return unavailable(agentPath, 'token counters decreased');
|
|
32
|
+
usage = next;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch { return unavailable(agentPath, 'session file could not be read'); }
|
|
36
|
+
if (!sessionId) return unavailable(agentPath, 'session metadata unavailable');
|
|
37
|
+
if (!complete) return unavailable(agentPath, 'task completion unavailable');
|
|
38
|
+
if (!usage) return unavailable(agentPath, 'token counters unavailable');
|
|
39
|
+
return { agentPath, status: 'available', sessionId, models, usage };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function collectUsage(manifest) {
|
|
43
|
+
if (!record(manifest) || typeof manifest.parentThreadId !== 'string' || !manifest.parentThreadId
|
|
44
|
+
|| !Array.isArray(manifest.agents) || !manifest.agents.length
|
|
45
|
+
|| manifest.agents.some(a => !record(a) || typeof a.agentPath !== 'string' || !a.agentPath || typeof a.sessionFile !== 'string' || !a.sessionFile)) {
|
|
46
|
+
return { available: false, agents: [], totals: null, reason: 'manifest needs parentThreadId and explicit agentPath/sessionFile entries' };
|
|
47
|
+
}
|
|
48
|
+
const paths = manifest.agents.map(a => a.agentPath), files = manifest.agents.map(a => a.sessionFile);
|
|
49
|
+
if (paths.some((p, i) => paths.indexOf(p) !== i) || files.some((p, i) => files.indexOf(p) !== i)) {
|
|
50
|
+
return { available: false, agents: [], totals: null, reason: 'manifest repeats an agent path or session file' };
|
|
51
|
+
}
|
|
52
|
+
let agents = [];
|
|
53
|
+
for (const agent of manifest.agents) agents = [...agents, await inspectSession(agent.sessionFile, manifest.parentThreadId, agent.agentPath)];
|
|
54
|
+
const available = agents.every(a => a.status !== 'unavailable');
|
|
55
|
+
const totals = available ? Object.fromEntries(FIELDS.map(k => [k, agents.reduce((n, a) => n + a.usage[k], 0)])) : null;
|
|
56
|
+
const safe = totals && FIELDS.every(k => Number.isSafeInteger(totals[k]));
|
|
57
|
+
return { available: Boolean(safe), agents, totals: safe ? totals : null, ...(available && !safe ? { reason: 'aggregate token counters exceed safe integer range' } : {}) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main(argv) {
|
|
61
|
+
const i = argv.indexOf('--manifest'), path = i < 0 ? null : argv[i + 1];
|
|
62
|
+
if (!path || path.startsWith('--')) { console.error('usage: codex-usage.mjs --manifest <explicit-sessions.json>'); process.exitCode = 2; return; }
|
|
63
|
+
let result;
|
|
64
|
+
try { result = await collectUsage(JSON.parse(readFileSync(path, 'utf8'))); }
|
|
65
|
+
catch { result = { available: false, agents: [], totals: null, reason: 'manifest could not be read' }; process.exitCode = 2; }
|
|
66
|
+
console.log(JSON.stringify(result));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { TextDecoder } from 'node:util';
|
|
4
|
+
import { riskPaths } from './risk-paths.mjs';
|
|
5
|
+
|
|
6
|
+
const FULL_SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
|
|
7
|
+
const DIFF_FLAGS = ['--no-ext-diff', '--no-textconv', '--no-color', '--binary', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/', '--full-index', '--unified=3', '--inter-hunk-context=0', '--diff-algorithm=myers', '--indent-heuristic', '--no-relative', '--ignore-submodules=none', '--submodule=short', '-O/dev/null'];
|
|
8
|
+
const unavailable = reason => ({ schema: 1, status: 'unavailable', reason });
|
|
9
|
+
const git = (cwd, args) => execFileSync('git', ['--no-replace-objects', '-c', 'core.quotePath=true', '-c', 'diff.suppressBlankEmpty=false', '-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024, env: Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))) });
|
|
10
|
+
const fullSha = value => typeof value === 'string' && FULL_SHA.test(value);
|
|
11
|
+
const headOf = cwd => git(cwd, ['rev-parse', '--verify', 'HEAD^{commit}']).toString('ascii').trim().toLowerCase();
|
|
12
|
+
const trackedStatus = cwd => git(cwd, ['status', '--porcelain', '--untracked-files=no', '--ignore-submodules=none']);
|
|
13
|
+
const flaggedIndex = cwd => git(cwd, ['ls-files', '-v', '-z']).toString().split('\0').some(row => /^[a-z] |^S /.test(row));
|
|
14
|
+
|
|
15
|
+
export function collectGitEvidence({ worktreePath, mergeBase, headSha } = {}) {
|
|
16
|
+
if (typeof worktreePath !== 'string' || !worktreePath || !fullSha(mergeBase) || !fullSha(headSha)) return unavailable('worktree path or full commit SHA is invalid');
|
|
17
|
+
try {
|
|
18
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
19
|
+
const baseSha = git(worktreePath, ['rev-parse', '--verify', `${mergeBase}^{commit}`]).toString('ascii').trim().toLowerCase();
|
|
20
|
+
if (baseSha !== mergeBase.toLowerCase()) return unavailable('merge base does not resolve to the pinned commit');
|
|
21
|
+
const headBefore = headOf(worktreePath);
|
|
22
|
+
if (headBefore !== headSha.toLowerCase()) return unavailable('worktree HEAD does not match pinned head');
|
|
23
|
+
if (flaggedIndex(worktreePath)) return unavailable('tracked index has assume-unchanged or skip-worktree entries before collection');
|
|
24
|
+
if (trackedStatus(worktreePath).length) return unavailable('tracked worktree is dirty before collection');
|
|
25
|
+
const rawDiff = git(worktreePath, ['diff', ...DIFF_FLAGS, baseSha, headBefore, '--']);
|
|
26
|
+
const rawPaths = git(worktreePath, ['diff', '--name-only', '-z', ...DIFF_FLAGS, baseSha, headBefore, '--']);
|
|
27
|
+
const headAfter = headOf(worktreePath);
|
|
28
|
+
if (headAfter !== headBefore) return unavailable('worktree HEAD changed during collection');
|
|
29
|
+
if (flaggedIndex(worktreePath)) return unavailable('tracked index gained assume-unchanged or skip-worktree entries during collection');
|
|
30
|
+
if (trackedStatus(worktreePath).length) return unavailable('tracked worktree became dirty during collection');
|
|
31
|
+
const changedPaths = rawPaths.toString('latin1').split('\0').filter(Boolean)
|
|
32
|
+
.map(path => decoder.decode(Buffer.from(path, 'latin1'))).sort();
|
|
33
|
+
const text = decoder.decode(rawDiff);
|
|
34
|
+
return { schema: 1, status: 'available', provenance: { baseSha, headSha: headAfter, trackedCleanBefore: true, trackedCleanAfter: true },
|
|
35
|
+
changedPaths, diff: { sha256: createHash('sha256').update(rawDiff).digest('hex'), text }, risk: riskPaths(changedPaths, text) };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return unavailable(error instanceof TypeError ? 'git evidence is not valid UTF-8' : 'git evidence command failed');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { readFileSync } from 'node:fs'
|
|
6
6
|
import { dirname, resolve } from 'node:path'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { bugClassOf, extractBadCode, mapSeverity } from './revmux-normalization.mjs'
|
|
8
9
|
|
|
9
10
|
const args = process.argv.slice(2)
|
|
10
11
|
const reportPath = args[0]
|
|
@@ -97,32 +98,6 @@ function extractRule(title) {
|
|
|
97
98
|
return m && HARD_RULES.has(m[1]) ? m[1] : undefined
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
// revmux reports one location per finding and no class, so the class is derived
|
|
101
|
-
// from the rule tag or title and no other instances are claimed.
|
|
102
|
-
function bugClassOf(rule, title) {
|
|
103
|
-
const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
|
|
104
|
-
return words.slice(0, 6).join('-') || 'unclassified'
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function mapSeverity(f) {
|
|
108
|
-
if (f.severity === 'critical') return 'critical'
|
|
109
|
-
if (f.severity === 'major') return 'important'
|
|
110
|
-
if (f.severity === 'minor') {
|
|
111
|
-
return (f.lenses || []).includes('lekker-conventions') ? 'idiomatic' : 'observation'
|
|
112
|
-
}
|
|
113
|
-
return 'observation'
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Pull an inline code snippet out of the body so hardRuleCorroborated has
|
|
117
|
-
// something to check against; revmux findings carry no separate badCode field.
|
|
118
|
-
function extractBadCode(body) {
|
|
119
|
-
const text = String(body || '')
|
|
120
|
-
const backtick = /`([^`]+)`/.exec(text)
|
|
121
|
-
if (backtick) return backtick[1]
|
|
122
|
-
const dashCode = /--\s*(.+)$/.exec(text.split('\n')[0])
|
|
123
|
-
return dashCode ? dashCode[1] : ''
|
|
124
|
-
}
|
|
125
|
-
|
|
126
101
|
const findings = []
|
|
127
102
|
let droppedCount = 0
|
|
128
103
|
let hardRuleCount = 0
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// revmux reports one location per finding and no class, so the class is derived
|
|
2
|
+
// from the rule tag or title and no other instances are claimed.
|
|
3
|
+
function bugClassOf(rule, title) {
|
|
4
|
+
const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
|
|
5
|
+
return words.slice(0, 6).join('-') || 'unclassified'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function mapSeverity(f) {
|
|
9
|
+
if (f.severity === 'critical') return 'critical'
|
|
10
|
+
if (f.severity === 'major') return 'important'
|
|
11
|
+
if (f.severity === 'minor') {
|
|
12
|
+
return (f.lenses || []).includes('lekker-conventions') ? 'idiomatic' : 'observation'
|
|
13
|
+
}
|
|
14
|
+
return 'observation'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Pull an inline code snippet out of the body so hardRuleCorroborated has
|
|
18
|
+
// something to check against; revmux findings carry no separate badCode field.
|
|
19
|
+
function extractBadCode(body) {
|
|
20
|
+
const text = String(body || '')
|
|
21
|
+
const backtick = /`([^`]+)`/.exec(text)
|
|
22
|
+
if (backtick) return backtick[1]
|
|
23
|
+
const dashCode = /--\s*(.+)$/.exec(text.split('\n')[0])
|
|
24
|
+
return dashCode ? dashCode[1] : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { bugClassOf, extractBadCode, mapSeverity }
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// `finish` may be called more than once for one run (after the review, again
|
|
28
28
|
// after fix mode); every call appends a fresh row and the newest one wins.
|
|
29
29
|
|
|
30
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
|
|
30
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, mkdtempSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
|
|
31
31
|
import { homedir } from 'node:os';
|
|
32
32
|
import { basename, join } from 'node:path';
|
|
33
33
|
|
|
@@ -152,9 +152,10 @@ if (cmd === 'start') {
|
|
|
152
152
|
const target = flag('target');
|
|
153
153
|
if (!repo || !target) fail('start needs --repo and --target');
|
|
154
154
|
const startedAt = new Date().toISOString();
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
const prefix = `${startedAt.replace(/[-:]/g, '').slice(0, 15)}-${slug(repo.split('/').pop())}-${slug(target)}-`.slice(0, 249);
|
|
156
|
+
mkdirSync(RUNS_DIR, { recursive: true });
|
|
157
|
+
const dir = mkdtempSync(join(RUNS_DIR, prefix));
|
|
158
|
+
const runId = basename(dir);
|
|
158
159
|
const meta = {
|
|
159
160
|
schema: 1, runId, startedAt, repo, target,
|
|
160
161
|
mode: flag('mode', 'pr'), depth: flag('depth'), engine: flag('engine', 'workflow'),
|
|
@@ -414,7 +414,9 @@ ESLINT_SCOPE_FILE="/tmp/lekker-eslint-scope-$$.txt"
|
|
|
414
414
|
# temp files): lint-findings.mjs turns it into botPredicted findings, so the
|
|
415
415
|
# review can fix what the bot reviewers would have charged for. Exported by
|
|
416
416
|
# path as eslintJson.
|
|
417
|
-
|
|
417
|
+
ESLINT_ARTIFACT_DIR="$(mktemp -d "$(dirname "$OUT_JSON")/eslint.XXXXXX")"
|
|
418
|
+
ESLINT_JSON_FILE="$ESLINT_ARTIFACT_DIR/eslint-report.json"
|
|
419
|
+
ESLINT_PROVENANCE_FILE="$ESLINT_JSON_FILE.provenance.json"
|
|
418
420
|
printf '' > "$TSC_TAIL_FILE"
|
|
419
421
|
printf '' > "$TSC_CHANGED_FILE"
|
|
420
422
|
printf '0' > "$TSC_COUNT_FILE"
|
|
@@ -422,6 +424,37 @@ printf '' > "$ESLINT_TAIL_FILE"
|
|
|
422
424
|
printf 'skipped' > "$ESLINT_SCOPE_FILE"
|
|
423
425
|
printf '' > "$ESLINT_JSON_FILE"
|
|
424
426
|
|
|
427
|
+
write_eslint_provenance() {
|
|
428
|
+
node - "$ESLINT_JSON_FILE" "$ESLINT_PROVENANCE_FILE" "$MERGE_BASE_VAL" "$@" <<'NODE' || rm -f "$ESLINT_PROVENANCE_FILE"
|
|
429
|
+
const { createHash } = require('node:crypto')
|
|
430
|
+
const fs = require('node:fs')
|
|
431
|
+
const [reportPath, receiptPath, baseSha, headBefore, headAfter, cleanBefore, cleanAfter, rawExit] = process.argv.slice(2)
|
|
432
|
+
const fullSha = value => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value || '')
|
|
433
|
+
let bytes = null, validReport = false
|
|
434
|
+
try {
|
|
435
|
+
bytes = fs.readFileSync(reportPath)
|
|
436
|
+
const report = JSON.parse(bytes.toString('utf8'))
|
|
437
|
+
validReport = bytes.length > 0 && Array.isArray(report) && report.length > 0
|
|
438
|
+
&& report.every(file => file && typeof file.filePath === 'string' && file.filePath.trim() && Array.isArray(file.messages))
|
|
439
|
+
} catch {}
|
|
440
|
+
const exitCode = rawExit === '' ? null : Number(rawExit)
|
|
441
|
+
const reasons = []
|
|
442
|
+
if (!fullSha(baseSha)) reasons.push('invalid base SHA')
|
|
443
|
+
if (!fullSha(headBefore) || headBefore !== headAfter) reasons.push('HEAD missing or changed during lint')
|
|
444
|
+
if (cleanBefore !== 'true' || cleanAfter !== 'true') reasons.push('tracked worktree was dirty')
|
|
445
|
+
if (exitCode !== 0 && exitCode !== 1) reasons.push('ESLint did not complete with a result')
|
|
446
|
+
if (!validReport) reasons.push('ESLint report missing or malformed')
|
|
447
|
+
fs.writeFileSync(receiptPath, JSON.stringify({
|
|
448
|
+
schema: 1, status: reasons.length ? 'unavailable' : 'available',
|
|
449
|
+
...(reasons.length ? { reason: reasons.join('; ') } : {}),
|
|
450
|
+
baseSha: baseSha === 'null' ? null : baseSha,
|
|
451
|
+
headShaBefore: headBefore || null, headShaAfter: headAfter || null,
|
|
452
|
+
trackedCleanBefore: cleanBefore === 'true', trackedCleanAfter: cleanAfter === 'true',
|
|
453
|
+
exitCode, reportSha256: bytes ? createHash('sha256').update(bytes).digest('hex') : null,
|
|
454
|
+
}, null, 2) + '\n')
|
|
455
|
+
NODE
|
|
456
|
+
}
|
|
457
|
+
|
|
425
458
|
# Render a stylish-style tail from the JSON report. An empty or unparseable
|
|
426
459
|
# report (eslint crashed, bad config) keeps whatever eslint wrote on stderr
|
|
427
460
|
# instead of rendering "0 errors" from nothing.
|
|
@@ -473,7 +506,7 @@ run_tsc() {
|
|
|
473
506
|
}
|
|
474
507
|
|
|
475
508
|
run_eslint() {
|
|
476
|
-
local has_config
|
|
509
|
+
local has_config head_before='' head_after='' clean_before=false clean_after=false lint_exit='' tracked_state
|
|
477
510
|
has_config=false
|
|
478
511
|
if [[ "$NM_LINKED" == "true" ]]; then
|
|
479
512
|
(cd "$WORKTREE_PATH" && compgen -G '.eslintrc*' >/dev/null 2>&1) && has_config=true
|
|
@@ -486,8 +519,17 @@ run_eslint() {
|
|
|
486
519
|
# One run, JSON formatter: the machine-readable report feeds
|
|
487
520
|
# lint-findings.mjs and the human tail is rendered from the
|
|
488
521
|
# same report, so the two can never disagree.
|
|
489
|
-
(
|
|
490
|
-
|
|
522
|
+
head_before="$(git -C "$WORKTREE_PATH" rev-parse HEAD 2>/dev/null || true)"
|
|
523
|
+
if tracked_state="$(git -C "$WORKTREE_PATH" status --porcelain=v1 --untracked-files=no 2>/dev/null)"; then
|
|
524
|
+
[[ -z "$tracked_state" ]] && clean_before=true
|
|
525
|
+
fi
|
|
526
|
+
lint_exit=0
|
|
527
|
+
(cd "$WORKTREE_PATH" && npx eslint --max-warnings=0 -f json -o "$ESLINT_JSON_FILE" "${ESLINT_TARGETS[@]}" 2> "$ESLINT_TAIL_FILE") || lint_exit=$?
|
|
528
|
+
head_after="$(git -C "$WORKTREE_PATH" rev-parse HEAD 2>/dev/null || true)"
|
|
529
|
+
if tracked_state="$(git -C "$WORKTREE_PATH" status --porcelain=v1 --untracked-files=no 2>/dev/null)"; then
|
|
530
|
+
[[ -z "$tracked_state" ]] && clean_after=true
|
|
531
|
+
fi
|
|
532
|
+
render_eslint_tail || true
|
|
491
533
|
printf 'changed-files' > "$ESLINT_SCOPE_FILE"
|
|
492
534
|
else
|
|
493
535
|
printf 'no-changed-lintable-files\n' > "$ESLINT_TAIL_FILE"
|
|
@@ -508,6 +550,7 @@ run_eslint() {
|
|
|
508
550
|
printf 'skipped\n' > "$ESLINT_TAIL_FILE"
|
|
509
551
|
printf 'skipped' > "$ESLINT_SCOPE_FILE"
|
|
510
552
|
fi
|
|
553
|
+
write_eslint_provenance "$head_before" "$head_after" "$clean_before" "$clean_after" "$lint_exit"
|
|
511
554
|
}
|
|
512
555
|
|
|
513
556
|
run_tsc &
|
|
@@ -582,6 +625,7 @@ python3 - \
|
|
|
582
625
|
"$MERGE_BASE_VAL" \
|
|
583
626
|
"$DEPS_INSTALL_FILE" \
|
|
584
627
|
"$ESLINT_JSON_FILE" \
|
|
628
|
+
"$ESLINT_PROVENANCE_FILE" \
|
|
585
629
|
"$TEST_RUNNER_JSON" \
|
|
586
630
|
"$OUT_JSON" \
|
|
587
631
|
"$BASE_RULES_FILE" \
|
|
@@ -592,8 +636,8 @@ import json, sys, os
|
|
|
592
636
|
tsc_file, eslint_file, rules_file, delta_file, notes,
|
|
593
637
|
changed_files_file, base_ref_val, eslint_scope_file,
|
|
594
638
|
tsc_changed_file, tsc_count_file, merge_base_val,
|
|
595
|
-
deps_install_file, eslint_json_file, test_runner_json, out_json,
|
|
596
|
-
base_rules_file, base_rules_state) = sys.argv[1:
|
|
639
|
+
deps_install_file, eslint_json_file, eslint_provenance_file, test_runner_json, out_json,
|
|
640
|
+
base_rules_file, base_rules_state) = sys.argv[1:23]
|
|
597
641
|
|
|
598
642
|
def parse_runner(raw):
|
|
599
643
|
try:
|
|
@@ -665,6 +709,7 @@ data = {
|
|
|
665
709
|
# no structured report (skipped, no config, crashed, or the repo-wide
|
|
666
710
|
# fallback), so a consumer can tell "no lint report" from "no problems".
|
|
667
711
|
"eslintJson": eslint_json_file if file_has_content(eslint_json_file) else None,
|
|
712
|
+
"eslintProvenance": eslint_provenance_file if file_has_content(eslint_provenance_file) else None,
|
|
668
713
|
}
|
|
669
714
|
|
|
670
715
|
for p in (tsc_file, eslint_file, rules_file, base_rules_file, changed_files_file,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i
|
|
4
|
+
const HASH = /^[0-9a-f]{64}$/i
|
|
5
|
+
const unavailable = reason => ({ status: 'unavailable', reason })
|
|
6
|
+
|
|
7
|
+
export function validateLintReceipt(receipt, expected, reportBytes) {
|
|
8
|
+
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) return unavailable('receipt missing or malformed')
|
|
9
|
+
if (!expected || typeof expected !== 'object' || Array.isArray(expected)) return unavailable('expected provenance missing')
|
|
10
|
+
if (!(reportBytes instanceof Uint8Array)) return unavailable('raw lint report bytes unavailable')
|
|
11
|
+
if (typeof expected.baseSha !== 'string' || !FULL_SHA.test(expected.baseSha)
|
|
12
|
+
|| typeof expected.headSha !== 'string' || !FULL_SHA.test(expected.headSha)) return unavailable('expected base/head must be full SHAs')
|
|
13
|
+
if (expected.trackedClean !== true) return unavailable('current tracked worktree is not clean')
|
|
14
|
+
if (receipt.schema !== 1) return unavailable('unsupported receipt schema')
|
|
15
|
+
if (receipt.status !== 'available') return unavailable(typeof receipt.reason === 'string' ? receipt.reason : 'receipt status is unavailable')
|
|
16
|
+
if (typeof receipt.baseSha !== 'string' || !FULL_SHA.test(receipt.baseSha)
|
|
17
|
+
|| receipt.baseSha.toLowerCase() !== expected.baseSha.toLowerCase()) return unavailable('base SHA mismatch')
|
|
18
|
+
if (typeof receipt.headShaBefore !== 'string' || !FULL_SHA.test(receipt.headShaBefore)
|
|
19
|
+
|| typeof receipt.headShaAfter !== 'string' || !FULL_SHA.test(receipt.headShaAfter)
|
|
20
|
+
|| receipt.headShaBefore.toLowerCase() !== receipt.headShaAfter.toLowerCase()
|
|
21
|
+
|| receipt.headShaBefore.toLowerCase() !== expected.headSha.toLowerCase()) return unavailable('head SHA mismatch or drift')
|
|
22
|
+
if (receipt.trackedCleanBefore !== true || receipt.trackedCleanAfter !== true) return unavailable('captured tracked worktree was not clean')
|
|
23
|
+
if (receipt.exitCode !== 0 && receipt.exitCode !== 1) return unavailable('invalid ESLint exit code')
|
|
24
|
+
if (typeof receipt.reportSha256 !== 'string' || !HASH.test(receipt.reportSha256)) return unavailable('report SHA-256 missing or malformed')
|
|
25
|
+
const reportSha256 = createHash('sha256').update(reportBytes).digest('hex')
|
|
26
|
+
if (reportSha256 !== receipt.reportSha256.toLowerCase()) return unavailable('lint report hash mismatch')
|
|
27
|
+
return {
|
|
28
|
+
status: 'available',
|
|
29
|
+
baseSha: receipt.baseSha, headSha: receipt.headShaBefore,
|
|
30
|
+
exitCode: receipt.exitCode, reportSha256,
|
|
31
|
+
trackedCleanBefore: receipt.trackedCleanBefore, trackedCleanAfter: receipt.trackedCleanAfter,
|
|
32
|
+
}
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -510,9 +510,11 @@ ${SKILL_ROOT}/scripts/setup-worktree.sh \
|
|
|
510
510
|
On completion, read `worktree.json`. Keys emitted:
|
|
511
511
|
`worktreePath`, `repoRoot`, `headSha`, `headShaShort`, `tscTail`,
|
|
512
512
|
`tscChangedTail`, `tscErrorCount`, `eslintTail`, `eslintScope`, `eslintJson`,
|
|
513
|
-
`changedFiles`, `baseRef`, `mergeBase`, `projectRules`, `deltaFile`, `notes`,
|
|
513
|
+
`eslintProvenance`, `changedFiles`, `baseRef`, `mergeBase`, `projectRules`, `deltaFile`, `notes`,
|
|
514
514
|
`depsInstall`, `testRunner`.
|
|
515
515
|
|
|
516
|
+
`eslintProvenance` is the provenance-only receipt for `eslintJson` (base/head, tracked-worktree state, exit code, raw report hash).
|
|
517
|
+
|
|
516
518
|
`testRunner` is `{runner, dirs, reason}` from `scripts/test-runner.mjs`:
|
|
517
519
|
`runner` is `vitest|jest|mocha|node-test` only when its binary is installed
|
|
518
520
|
in the worktree, `dirs` lists `{dir, runner}` pairs for each usable package
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createReadStream, readFileSync } from 'node:fs';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const FIELDS = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens'];
|
|
7
|
+
const record = v => v && typeof v === 'object' && !Array.isArray(v);
|
|
8
|
+
const unavailable = (agentPath, reason) => ({ agentPath, status: 'unavailable', reason });
|
|
9
|
+
|
|
10
|
+
async function inspectSession(sessionFile, parentThreadId, agentPath) {
|
|
11
|
+
let sessionId = null, usage = null, complete = false, models = [];
|
|
12
|
+
try {
|
|
13
|
+
const input = createReadStream(sessionFile);
|
|
14
|
+
for await (const line of createInterface({ input, crlfDelay: Infinity })) {
|
|
15
|
+
if (!line.trim()) continue;
|
|
16
|
+
let row;
|
|
17
|
+
try { row = JSON.parse(line); } catch { return unavailable(agentPath, 'malformed JSONL'); }
|
|
18
|
+
if (row.type === 'session_meta') {
|
|
19
|
+
const spawn = row.payload?.source?.subagent?.thread_spawn;
|
|
20
|
+
if (sessionId || typeof row.payload?.id !== 'string' || !row.payload.id) return unavailable(agentPath, 'session metadata missing or duplicated');
|
|
21
|
+
if (spawn?.parent_thread_id !== parentThreadId || spawn?.agent_path !== agentPath) return unavailable(agentPath, 'parent thread or agent path mismatch');
|
|
22
|
+
sessionId = row.payload.id;
|
|
23
|
+
} else if (row.type === 'turn_context' && typeof row.payload?.model === 'string' && row.payload.model && !models.includes(row.payload.model)) {
|
|
24
|
+
models = [...models, row.payload.model];
|
|
25
|
+
} else if (row.type === 'event_msg' && row.payload?.type === 'task_started') complete = false;
|
|
26
|
+
else if (row.type === 'event_msg' && row.payload?.type === 'task_complete') complete = true;
|
|
27
|
+
else if (row.type === 'event_msg' && row.payload?.type === 'token_count') {
|
|
28
|
+
const raw = row.payload.info?.total_token_usage;
|
|
29
|
+
if (!record(raw) || FIELDS.some(k => !Number.isSafeInteger(raw[k]) || raw[k] < 0)) return unavailable(agentPath, 'token counters are malformed');
|
|
30
|
+
const next = Object.fromEntries(FIELDS.map(k => [k, raw[k]]));
|
|
31
|
+
if (usage && FIELDS.some(k => next[k] < usage[k])) return unavailable(agentPath, 'token counters decreased');
|
|
32
|
+
usage = next;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch { return unavailable(agentPath, 'session file could not be read'); }
|
|
36
|
+
if (!sessionId) return unavailable(agentPath, 'session metadata unavailable');
|
|
37
|
+
if (!complete) return unavailable(agentPath, 'task completion unavailable');
|
|
38
|
+
if (!usage) return unavailable(agentPath, 'token counters unavailable');
|
|
39
|
+
return { agentPath, status: 'available', sessionId, models, usage };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function collectUsage(manifest) {
|
|
43
|
+
if (!record(manifest) || typeof manifest.parentThreadId !== 'string' || !manifest.parentThreadId
|
|
44
|
+
|| !Array.isArray(manifest.agents) || !manifest.agents.length
|
|
45
|
+
|| manifest.agents.some(a => !record(a) || typeof a.agentPath !== 'string' || !a.agentPath || typeof a.sessionFile !== 'string' || !a.sessionFile)) {
|
|
46
|
+
return { available: false, agents: [], totals: null, reason: 'manifest needs parentThreadId and explicit agentPath/sessionFile entries' };
|
|
47
|
+
}
|
|
48
|
+
const paths = manifest.agents.map(a => a.agentPath), files = manifest.agents.map(a => a.sessionFile);
|
|
49
|
+
if (paths.some((p, i) => paths.indexOf(p) !== i) || files.some((p, i) => files.indexOf(p) !== i)) {
|
|
50
|
+
return { available: false, agents: [], totals: null, reason: 'manifest repeats an agent path or session file' };
|
|
51
|
+
}
|
|
52
|
+
let agents = [];
|
|
53
|
+
for (const agent of manifest.agents) agents = [...agents, await inspectSession(agent.sessionFile, manifest.parentThreadId, agent.agentPath)];
|
|
54
|
+
const available = agents.every(a => a.status !== 'unavailable');
|
|
55
|
+
const totals = available ? Object.fromEntries(FIELDS.map(k => [k, agents.reduce((n, a) => n + a.usage[k], 0)])) : null;
|
|
56
|
+
const safe = totals && FIELDS.every(k => Number.isSafeInteger(totals[k]));
|
|
57
|
+
return { available: Boolean(safe), agents, totals: safe ? totals : null, ...(available && !safe ? { reason: 'aggregate token counters exceed safe integer range' } : {}) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main(argv) {
|
|
61
|
+
const i = argv.indexOf('--manifest'), path = i < 0 ? null : argv[i + 1];
|
|
62
|
+
if (!path || path.startsWith('--')) { console.error('usage: codex-usage.mjs --manifest <explicit-sessions.json>'); process.exitCode = 2; return; }
|
|
63
|
+
let result;
|
|
64
|
+
try { result = await collectUsage(JSON.parse(readFileSync(path, 'utf8'))); }
|
|
65
|
+
catch { result = { available: false, agents: [], totals: null, reason: 'manifest could not be read' }; process.exitCode = 2; }
|
|
66
|
+
console.log(JSON.stringify(result));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { TextDecoder } from 'node:util';
|
|
4
|
+
import { riskPaths } from './risk-paths.mjs';
|
|
5
|
+
|
|
6
|
+
const FULL_SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
|
|
7
|
+
const DIFF_FLAGS = ['--no-ext-diff', '--no-textconv', '--no-color', '--binary', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/', '--full-index', '--unified=3', '--inter-hunk-context=0', '--diff-algorithm=myers', '--indent-heuristic', '--no-relative', '--ignore-submodules=none', '--submodule=short', '-O/dev/null'];
|
|
8
|
+
const unavailable = reason => ({ schema: 1, status: 'unavailable', reason });
|
|
9
|
+
const git = (cwd, args) => execFileSync('git', ['--no-replace-objects', '-c', 'core.quotePath=true', '-c', 'diff.suppressBlankEmpty=false', '-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024, env: Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))) });
|
|
10
|
+
const fullSha = value => typeof value === 'string' && FULL_SHA.test(value);
|
|
11
|
+
const headOf = cwd => git(cwd, ['rev-parse', '--verify', 'HEAD^{commit}']).toString('ascii').trim().toLowerCase();
|
|
12
|
+
const trackedStatus = cwd => git(cwd, ['status', '--porcelain', '--untracked-files=no', '--ignore-submodules=none']);
|
|
13
|
+
const flaggedIndex = cwd => git(cwd, ['ls-files', '-v', '-z']).toString().split('\0').some(row => /^[a-z] |^S /.test(row));
|
|
14
|
+
|
|
15
|
+
export function collectGitEvidence({ worktreePath, mergeBase, headSha } = {}) {
|
|
16
|
+
if (typeof worktreePath !== 'string' || !worktreePath || !fullSha(mergeBase) || !fullSha(headSha)) return unavailable('worktree path or full commit SHA is invalid');
|
|
17
|
+
try {
|
|
18
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
19
|
+
const baseSha = git(worktreePath, ['rev-parse', '--verify', `${mergeBase}^{commit}`]).toString('ascii').trim().toLowerCase();
|
|
20
|
+
if (baseSha !== mergeBase.toLowerCase()) return unavailable('merge base does not resolve to the pinned commit');
|
|
21
|
+
const headBefore = headOf(worktreePath);
|
|
22
|
+
if (headBefore !== headSha.toLowerCase()) return unavailable('worktree HEAD does not match pinned head');
|
|
23
|
+
if (flaggedIndex(worktreePath)) return unavailable('tracked index has assume-unchanged or skip-worktree entries before collection');
|
|
24
|
+
if (trackedStatus(worktreePath).length) return unavailable('tracked worktree is dirty before collection');
|
|
25
|
+
const rawDiff = git(worktreePath, ['diff', ...DIFF_FLAGS, baseSha, headBefore, '--']);
|
|
26
|
+
const rawPaths = git(worktreePath, ['diff', '--name-only', '-z', ...DIFF_FLAGS, baseSha, headBefore, '--']);
|
|
27
|
+
const headAfter = headOf(worktreePath);
|
|
28
|
+
if (headAfter !== headBefore) return unavailable('worktree HEAD changed during collection');
|
|
29
|
+
if (flaggedIndex(worktreePath)) return unavailable('tracked index gained assume-unchanged or skip-worktree entries during collection');
|
|
30
|
+
if (trackedStatus(worktreePath).length) return unavailable('tracked worktree became dirty during collection');
|
|
31
|
+
const changedPaths = rawPaths.toString('latin1').split('\0').filter(Boolean)
|
|
32
|
+
.map(path => decoder.decode(Buffer.from(path, 'latin1'))).sort();
|
|
33
|
+
const text = decoder.decode(rawDiff);
|
|
34
|
+
return { schema: 1, status: 'available', provenance: { baseSha, headSha: headAfter, trackedCleanBefore: true, trackedCleanAfter: true },
|
|
35
|
+
changedPaths, diff: { sha256: createHash('sha256').update(rawDiff).digest('hex'), text }, risk: riskPaths(changedPaths, text) };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return unavailable(error instanceof TypeError ? 'git evidence is not valid UTF-8' : 'git evidence command failed');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { readFileSync } from 'node:fs'
|
|
6
6
|
import { dirname, resolve } from 'node:path'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { bugClassOf, extractBadCode, mapSeverity } from './revmux-normalization.mjs'
|
|
8
9
|
|
|
9
10
|
const args = process.argv.slice(2)
|
|
10
11
|
const reportPath = args[0]
|
|
@@ -97,32 +98,6 @@ function extractRule(title) {
|
|
|
97
98
|
return m && HARD_RULES.has(m[1]) ? m[1] : undefined
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
// revmux reports one location per finding and no class, so the class is derived
|
|
101
|
-
// from the rule tag or title and no other instances are claimed.
|
|
102
|
-
function bugClassOf(rule, title) {
|
|
103
|
-
const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
|
|
104
|
-
return words.slice(0, 6).join('-') || 'unclassified'
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function mapSeverity(f) {
|
|
108
|
-
if (f.severity === 'critical') return 'critical'
|
|
109
|
-
if (f.severity === 'major') return 'important'
|
|
110
|
-
if (f.severity === 'minor') {
|
|
111
|
-
return (f.lenses || []).includes('lekker-conventions') ? 'idiomatic' : 'observation'
|
|
112
|
-
}
|
|
113
|
-
return 'observation'
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Pull an inline code snippet out of the body so hardRuleCorroborated has
|
|
117
|
-
// something to check against; revmux findings carry no separate badCode field.
|
|
118
|
-
function extractBadCode(body) {
|
|
119
|
-
const text = String(body || '')
|
|
120
|
-
const backtick = /`([^`]+)`/.exec(text)
|
|
121
|
-
if (backtick) return backtick[1]
|
|
122
|
-
const dashCode = /--\s*(.+)$/.exec(text.split('\n')[0])
|
|
123
|
-
return dashCode ? dashCode[1] : ''
|
|
124
|
-
}
|
|
125
|
-
|
|
126
101
|
const findings = []
|
|
127
102
|
let droppedCount = 0
|
|
128
103
|
let hardRuleCount = 0
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// revmux reports one location per finding and no class, so the class is derived
|
|
2
|
+
// from the rule tag or title and no other instances are claimed.
|
|
3
|
+
function bugClassOf(rule, title) {
|
|
4
|
+
const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
|
|
5
|
+
return words.slice(0, 6).join('-') || 'unclassified'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function mapSeverity(f) {
|
|
9
|
+
if (f.severity === 'critical') return 'critical'
|
|
10
|
+
if (f.severity === 'major') return 'important'
|
|
11
|
+
if (f.severity === 'minor') {
|
|
12
|
+
return (f.lenses || []).includes('lekker-conventions') ? 'idiomatic' : 'observation'
|
|
13
|
+
}
|
|
14
|
+
return 'observation'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Pull an inline code snippet out of the body so hardRuleCorroborated has
|
|
18
|
+
// something to check against; revmux findings carry no separate badCode field.
|
|
19
|
+
function extractBadCode(body) {
|
|
20
|
+
const text = String(body || '')
|
|
21
|
+
const backtick = /`([^`]+)`/.exec(text)
|
|
22
|
+
if (backtick) return backtick[1]
|
|
23
|
+
const dashCode = /--\s*(.+)$/.exec(text.split('\n')[0])
|
|
24
|
+
return dashCode ? dashCode[1] : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { bugClassOf, extractBadCode, mapSeverity }
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// `finish` may be called more than once for one run (after the review, again
|
|
28
28
|
// after fix mode); every call appends a fresh row and the newest one wins.
|
|
29
29
|
|
|
30
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
|
|
30
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, mkdtempSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
|
|
31
31
|
import { homedir } from 'node:os';
|
|
32
32
|
import { basename, join } from 'node:path';
|
|
33
33
|
|
|
@@ -152,9 +152,10 @@ if (cmd === 'start') {
|
|
|
152
152
|
const target = flag('target');
|
|
153
153
|
if (!repo || !target) fail('start needs --repo and --target');
|
|
154
154
|
const startedAt = new Date().toISOString();
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
const prefix = `${startedAt.replace(/[-:]/g, '').slice(0, 15)}-${slug(repo.split('/').pop())}-${slug(target)}-`.slice(0, 249);
|
|
156
|
+
mkdirSync(RUNS_DIR, { recursive: true });
|
|
157
|
+
const dir = mkdtempSync(join(RUNS_DIR, prefix));
|
|
158
|
+
const runId = basename(dir);
|
|
158
159
|
const meta = {
|
|
159
160
|
schema: 1, runId, startedAt, repo, target,
|
|
160
161
|
mode: flag('mode', 'pr'), depth: flag('depth'), engine: flag('engine', 'workflow'),
|
|
@@ -414,7 +414,9 @@ ESLINT_SCOPE_FILE="/tmp/lekker-eslint-scope-$$.txt"
|
|
|
414
414
|
# temp files): lint-findings.mjs turns it into botPredicted findings, so the
|
|
415
415
|
# review can fix what the bot reviewers would have charged for. Exported by
|
|
416
416
|
# path as eslintJson.
|
|
417
|
-
|
|
417
|
+
ESLINT_ARTIFACT_DIR="$(mktemp -d "$(dirname "$OUT_JSON")/eslint.XXXXXX")"
|
|
418
|
+
ESLINT_JSON_FILE="$ESLINT_ARTIFACT_DIR/eslint-report.json"
|
|
419
|
+
ESLINT_PROVENANCE_FILE="$ESLINT_JSON_FILE.provenance.json"
|
|
418
420
|
printf '' > "$TSC_TAIL_FILE"
|
|
419
421
|
printf '' > "$TSC_CHANGED_FILE"
|
|
420
422
|
printf '0' > "$TSC_COUNT_FILE"
|
|
@@ -422,6 +424,37 @@ printf '' > "$ESLINT_TAIL_FILE"
|
|
|
422
424
|
printf 'skipped' > "$ESLINT_SCOPE_FILE"
|
|
423
425
|
printf '' > "$ESLINT_JSON_FILE"
|
|
424
426
|
|
|
427
|
+
write_eslint_provenance() {
|
|
428
|
+
node - "$ESLINT_JSON_FILE" "$ESLINT_PROVENANCE_FILE" "$MERGE_BASE_VAL" "$@" <<'NODE' || rm -f "$ESLINT_PROVENANCE_FILE"
|
|
429
|
+
const { createHash } = require('node:crypto')
|
|
430
|
+
const fs = require('node:fs')
|
|
431
|
+
const [reportPath, receiptPath, baseSha, headBefore, headAfter, cleanBefore, cleanAfter, rawExit] = process.argv.slice(2)
|
|
432
|
+
const fullSha = value => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value || '')
|
|
433
|
+
let bytes = null, validReport = false
|
|
434
|
+
try {
|
|
435
|
+
bytes = fs.readFileSync(reportPath)
|
|
436
|
+
const report = JSON.parse(bytes.toString('utf8'))
|
|
437
|
+
validReport = bytes.length > 0 && Array.isArray(report) && report.length > 0
|
|
438
|
+
&& report.every(file => file && typeof file.filePath === 'string' && file.filePath.trim() && Array.isArray(file.messages))
|
|
439
|
+
} catch {}
|
|
440
|
+
const exitCode = rawExit === '' ? null : Number(rawExit)
|
|
441
|
+
const reasons = []
|
|
442
|
+
if (!fullSha(baseSha)) reasons.push('invalid base SHA')
|
|
443
|
+
if (!fullSha(headBefore) || headBefore !== headAfter) reasons.push('HEAD missing or changed during lint')
|
|
444
|
+
if (cleanBefore !== 'true' || cleanAfter !== 'true') reasons.push('tracked worktree was dirty')
|
|
445
|
+
if (exitCode !== 0 && exitCode !== 1) reasons.push('ESLint did not complete with a result')
|
|
446
|
+
if (!validReport) reasons.push('ESLint report missing or malformed')
|
|
447
|
+
fs.writeFileSync(receiptPath, JSON.stringify({
|
|
448
|
+
schema: 1, status: reasons.length ? 'unavailable' : 'available',
|
|
449
|
+
...(reasons.length ? { reason: reasons.join('; ') } : {}),
|
|
450
|
+
baseSha: baseSha === 'null' ? null : baseSha,
|
|
451
|
+
headShaBefore: headBefore || null, headShaAfter: headAfter || null,
|
|
452
|
+
trackedCleanBefore: cleanBefore === 'true', trackedCleanAfter: cleanAfter === 'true',
|
|
453
|
+
exitCode, reportSha256: bytes ? createHash('sha256').update(bytes).digest('hex') : null,
|
|
454
|
+
}, null, 2) + '\n')
|
|
455
|
+
NODE
|
|
456
|
+
}
|
|
457
|
+
|
|
425
458
|
# Render a stylish-style tail from the JSON report. An empty or unparseable
|
|
426
459
|
# report (eslint crashed, bad config) keeps whatever eslint wrote on stderr
|
|
427
460
|
# instead of rendering "0 errors" from nothing.
|
|
@@ -473,7 +506,7 @@ run_tsc() {
|
|
|
473
506
|
}
|
|
474
507
|
|
|
475
508
|
run_eslint() {
|
|
476
|
-
local has_config
|
|
509
|
+
local has_config head_before='' head_after='' clean_before=false clean_after=false lint_exit='' tracked_state
|
|
477
510
|
has_config=false
|
|
478
511
|
if [[ "$NM_LINKED" == "true" ]]; then
|
|
479
512
|
(cd "$WORKTREE_PATH" && compgen -G '.eslintrc*' >/dev/null 2>&1) && has_config=true
|
|
@@ -486,8 +519,17 @@ run_eslint() {
|
|
|
486
519
|
# One run, JSON formatter: the machine-readable report feeds
|
|
487
520
|
# lint-findings.mjs and the human tail is rendered from the
|
|
488
521
|
# same report, so the two can never disagree.
|
|
489
|
-
(
|
|
490
|
-
|
|
522
|
+
head_before="$(git -C "$WORKTREE_PATH" rev-parse HEAD 2>/dev/null || true)"
|
|
523
|
+
if tracked_state="$(git -C "$WORKTREE_PATH" status --porcelain=v1 --untracked-files=no 2>/dev/null)"; then
|
|
524
|
+
[[ -z "$tracked_state" ]] && clean_before=true
|
|
525
|
+
fi
|
|
526
|
+
lint_exit=0
|
|
527
|
+
(cd "$WORKTREE_PATH" && npx eslint --max-warnings=0 -f json -o "$ESLINT_JSON_FILE" "${ESLINT_TARGETS[@]}" 2> "$ESLINT_TAIL_FILE") || lint_exit=$?
|
|
528
|
+
head_after="$(git -C "$WORKTREE_PATH" rev-parse HEAD 2>/dev/null || true)"
|
|
529
|
+
if tracked_state="$(git -C "$WORKTREE_PATH" status --porcelain=v1 --untracked-files=no 2>/dev/null)"; then
|
|
530
|
+
[[ -z "$tracked_state" ]] && clean_after=true
|
|
531
|
+
fi
|
|
532
|
+
render_eslint_tail || true
|
|
491
533
|
printf 'changed-files' > "$ESLINT_SCOPE_FILE"
|
|
492
534
|
else
|
|
493
535
|
printf 'no-changed-lintable-files\n' > "$ESLINT_TAIL_FILE"
|
|
@@ -508,6 +550,7 @@ run_eslint() {
|
|
|
508
550
|
printf 'skipped\n' > "$ESLINT_TAIL_FILE"
|
|
509
551
|
printf 'skipped' > "$ESLINT_SCOPE_FILE"
|
|
510
552
|
fi
|
|
553
|
+
write_eslint_provenance "$head_before" "$head_after" "$clean_before" "$clean_after" "$lint_exit"
|
|
511
554
|
}
|
|
512
555
|
|
|
513
556
|
run_tsc &
|
|
@@ -582,6 +625,7 @@ python3 - \
|
|
|
582
625
|
"$MERGE_BASE_VAL" \
|
|
583
626
|
"$DEPS_INSTALL_FILE" \
|
|
584
627
|
"$ESLINT_JSON_FILE" \
|
|
628
|
+
"$ESLINT_PROVENANCE_FILE" \
|
|
585
629
|
"$TEST_RUNNER_JSON" \
|
|
586
630
|
"$OUT_JSON" \
|
|
587
631
|
"$BASE_RULES_FILE" \
|
|
@@ -592,8 +636,8 @@ import json, sys, os
|
|
|
592
636
|
tsc_file, eslint_file, rules_file, delta_file, notes,
|
|
593
637
|
changed_files_file, base_ref_val, eslint_scope_file,
|
|
594
638
|
tsc_changed_file, tsc_count_file, merge_base_val,
|
|
595
|
-
deps_install_file, eslint_json_file, test_runner_json, out_json,
|
|
596
|
-
base_rules_file, base_rules_state) = sys.argv[1:
|
|
639
|
+
deps_install_file, eslint_json_file, eslint_provenance_file, test_runner_json, out_json,
|
|
640
|
+
base_rules_file, base_rules_state) = sys.argv[1:23]
|
|
597
641
|
|
|
598
642
|
def parse_runner(raw):
|
|
599
643
|
try:
|
|
@@ -665,6 +709,7 @@ data = {
|
|
|
665
709
|
# no structured report (skipped, no config, crashed, or the repo-wide
|
|
666
710
|
# fallback), so a consumer can tell "no lint report" from "no problems".
|
|
667
711
|
"eslintJson": eslint_json_file if file_has_content(eslint_json_file) else None,
|
|
712
|
+
"eslintProvenance": eslint_provenance_file if file_has_content(eslint_provenance_file) else None,
|
|
668
713
|
}
|
|
669
714
|
|
|
670
715
|
for p in (tsc_file, eslint_file, rules_file, base_rules_file, changed_files_file,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i
|
|
4
|
+
const HASH = /^[0-9a-f]{64}$/i
|
|
5
|
+
const unavailable = reason => ({ status: 'unavailable', reason })
|
|
6
|
+
|
|
7
|
+
export function validateLintReceipt(receipt, expected, reportBytes) {
|
|
8
|
+
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) return unavailable('receipt missing or malformed')
|
|
9
|
+
if (!expected || typeof expected !== 'object' || Array.isArray(expected)) return unavailable('expected provenance missing')
|
|
10
|
+
if (!(reportBytes instanceof Uint8Array)) return unavailable('raw lint report bytes unavailable')
|
|
11
|
+
if (typeof expected.baseSha !== 'string' || !FULL_SHA.test(expected.baseSha)
|
|
12
|
+
|| typeof expected.headSha !== 'string' || !FULL_SHA.test(expected.headSha)) return unavailable('expected base/head must be full SHAs')
|
|
13
|
+
if (expected.trackedClean !== true) return unavailable('current tracked worktree is not clean')
|
|
14
|
+
if (receipt.schema !== 1) return unavailable('unsupported receipt schema')
|
|
15
|
+
if (receipt.status !== 'available') return unavailable(typeof receipt.reason === 'string' ? receipt.reason : 'receipt status is unavailable')
|
|
16
|
+
if (typeof receipt.baseSha !== 'string' || !FULL_SHA.test(receipt.baseSha)
|
|
17
|
+
|| receipt.baseSha.toLowerCase() !== expected.baseSha.toLowerCase()) return unavailable('base SHA mismatch')
|
|
18
|
+
if (typeof receipt.headShaBefore !== 'string' || !FULL_SHA.test(receipt.headShaBefore)
|
|
19
|
+
|| typeof receipt.headShaAfter !== 'string' || !FULL_SHA.test(receipt.headShaAfter)
|
|
20
|
+
|| receipt.headShaBefore.toLowerCase() !== receipt.headShaAfter.toLowerCase()
|
|
21
|
+
|| receipt.headShaBefore.toLowerCase() !== expected.headSha.toLowerCase()) return unavailable('head SHA mismatch or drift')
|
|
22
|
+
if (receipt.trackedCleanBefore !== true || receipt.trackedCleanAfter !== true) return unavailable('captured tracked worktree was not clean')
|
|
23
|
+
if (receipt.exitCode !== 0 && receipt.exitCode !== 1) return unavailable('invalid ESLint exit code')
|
|
24
|
+
if (typeof receipt.reportSha256 !== 'string' || !HASH.test(receipt.reportSha256)) return unavailable('report SHA-256 missing or malformed')
|
|
25
|
+
const reportSha256 = createHash('sha256').update(reportBytes).digest('hex')
|
|
26
|
+
if (reportSha256 !== receipt.reportSha256.toLowerCase()) return unavailable('lint report hash mismatch')
|
|
27
|
+
return {
|
|
28
|
+
status: 'available',
|
|
29
|
+
baseSha: receipt.baseSha, headSha: receipt.headShaBefore,
|
|
30
|
+
exitCode: receipt.exitCode, reportSha256,
|
|
31
|
+
trackedCleanBefore: receipt.trackedCleanBefore, trackedCleanAfter: receipt.trackedCleanAfter,
|
|
32
|
+
}
|
|
33
|
+
}
|