@rigour-labs/core 6.7.10 → 6.8.1-rc.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/dist/brief/briefing.d.ts +59 -0
- package/dist/brief/briefing.js +126 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/inference/cloud-provider.js +12 -2
- package/dist/review/backtest-init.d.ts +8 -5
- package/dist/review/backtest-init.js +27 -9
- package/dist/review/backtest-last.js +3 -1
- package/dist/review/reviewer/adapters.d.ts +4 -0
- package/dist/review/reviewer/adapters.js +23 -0
- package/dist/review/reviewer/inputs.d.ts +4 -1
- package/dist/review/reviewer/inputs.js +36 -2
- package/dist/review/reviewer/record.d.ts +5 -0
- package/dist/review/reviewer/record.js +3 -3
- package/dist/review/reviewer/verdict.d.ts +14 -0
- package/dist/review/reviewer/verdict.js +46 -6
- package/dist/review/reviewer.d.ts +2 -0
- package/dist/review/reviewer.js +24 -5
- package/dist/review-learning/repo-rules.d.ts +14 -2
- package/dist/review-learning/repo-rules.js +59 -9
- package/dist/task/thread.d.ts +47 -0
- package/dist/task/thread.js +234 -0
- package/dist/templates/universal-config.js +4 -0
- package/dist/types/index.d.ts +21 -0
- package/dist/types/index.js +7 -0
- package/package.json +11 -8
- package/dist/context/automatic-index-cache.test.d.ts +0 -1
- package/dist/context/automatic-index-cache.test.js +0 -30
- package/dist/context/automatic-index.test.d.ts +0 -1
- package/dist/context/automatic-index.test.js +0 -45
- package/dist/context/cache-engine.test.d.ts +0 -1
- package/dist/context/cache-engine.test.js +0 -99
- package/dist/context/dependency-graph.test.d.ts +0 -1
- package/dist/context/dependency-graph.test.js +0 -13
- package/dist/context/index-status.test.d.ts +0 -1
- package/dist/context/index-status.test.js +0 -26
- package/dist/context.test.d.ts +0 -1
- package/dist/context.test.js +0 -228
- package/dist/deep/agent-review.test.d.ts +0 -1
- package/dist/deep/agent-review.test.js +0 -53
- package/dist/deep/code-context.test.d.ts +0 -1
- package/dist/deep/code-context.test.js +0 -48
- package/dist/deep/code-pass.test.d.ts +0 -1
- package/dist/deep/code-pass.test.js +0 -112
- package/dist/deep/code-review-prompt.test.d.ts +0 -1
- package/dist/deep/code-review-prompt.test.js +0 -20
- package/dist/deep/code-verifier.test.d.ts +0 -1
- package/dist/deep/code-verifier.test.js +0 -54
- package/dist/deep/diff-tests/calls.test.d.ts +0 -1
- package/dist/deep/diff-tests/calls.test.js +0 -33
- package/dist/deep/diff-tests/run.test.d.ts +0 -1
- package/dist/deep/diff-tests/run.test.js +0 -58
- package/dist/deep/fact-extractor.test.d.ts +0 -1
- package/dist/deep/fact-extractor.test.js +0 -581
- package/dist/deep/parse-findings.test.d.ts +0 -1
- package/dist/deep/parse-findings.test.js +0 -25
- package/dist/deep/pr-review.test.d.ts +0 -1
- package/dist/deep/pr-review.test.js +0 -80
- package/dist/deep/prompts.test.d.ts +0 -1
- package/dist/deep/prompts.test.js +0 -235
- package/dist/deep/reference-pack.test.d.ts +0 -1
- package/dist/deep/reference-pack.test.js +0 -46
- package/dist/deep/related-changes.test.d.ts +0 -1
- package/dist/deep/related-changes.test.js +0 -33
- package/dist/deep/review-context-export.test.d.ts +0 -1
- package/dist/deep/review-context-export.test.js +0 -32
- package/dist/deep/review-tools.test.d.ts +0 -1
- package/dist/deep/review-tools.test.js +0 -39
- package/dist/deep/risk.test.d.ts +0 -1
- package/dist/deep/risk.test.js +0 -101
- package/dist/deep/verifier.test.d.ts +0 -1
- package/dist/deep/verifier.test.js +0 -635
- package/dist/discovery.test.d.ts +0 -1
- package/dist/discovery.test.js +0 -93
- package/dist/environment.test.d.ts +0 -1
- package/dist/environment.test.js +0 -94
- package/dist/firewall/firewall.test.d.ts +0 -1
- package/dist/firewall/firewall.test.js +0 -117
- package/dist/firewall/trust-boundaries.test.d.ts +0 -1
- package/dist/firewall/trust-boundaries.test.js +0 -64
- package/dist/firewall/trusted-control.test.d.ts +0 -1
- package/dist/firewall/trusted-control.test.js +0 -188
- package/dist/gates/agent-team.test.d.ts +0 -1
- package/dist/gates/agent-team.test.js +0 -113
- package/dist/gates/ast.test.d.ts +0 -1
- package/dist/gates/ast.test.js +0 -112
- package/dist/gates/checkpoint.test.d.ts +0 -1
- package/dist/gates/checkpoint.test.js +0 -105
- package/dist/gates/content.test.d.ts +0 -1
- package/dist/gates/content.test.js +0 -73
- package/dist/gates/coverage.test.d.ts +0 -1
- package/dist/gates/coverage.test.js +0 -53
- package/dist/gates/dedupe-failures.test.d.ts +0 -1
- package/dist/gates/dedupe-failures.test.js +0 -12
- package/dist/gates/deep-analysis.test.d.ts +0 -1
- package/dist/gates/deep-analysis.test.js +0 -86
- package/dist/gates/deep-intent.test.d.ts +0 -1
- package/dist/gates/deep-intent.test.js +0 -51
- package/dist/gates/deep-timeout.test.d.ts +0 -1
- package/dist/gates/deep-timeout.test.js +0 -10
- package/dist/gates/deprecated-apis.test.d.ts +0 -1
- package/dist/gates/deprecated-apis.test.js +0 -318
- package/dist/gates/deprecated-dependencies.test.d.ts +0 -1
- package/dist/gates/deprecated-dependencies.test.js +0 -55
- package/dist/gates/frontend-secret-exposure.test.d.ts +0 -1
- package/dist/gates/frontend-secret-exposure.test.js +0 -148
- package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.d.ts +0 -1
- package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.js +0 -27
- package/dist/gates/hallucinated-imports/js-resolver-types.test.d.ts +0 -1
- package/dist/gates/hallucinated-imports/js-resolver-types.test.js +0 -14
- package/dist/gates/hallucinated-imports-sveltekit.test.d.ts +0 -1
- package/dist/gates/hallucinated-imports-sveltekit.test.js +0 -132
- package/dist/gates/hallucinated-imports.test.d.ts +0 -1
- package/dist/gates/hallucinated-imports.test.js +0 -1206
- package/dist/gates/js-style-context.test.d.ts +0 -1
- package/dist/gates/js-style-context.test.js +0 -35
- package/dist/gates/logic-drift.test.d.ts +0 -1
- package/dist/gates/logic-drift.test.js +0 -52
- package/dist/gates/phantom-apis.test.d.ts +0 -1
- package/dist/gates/phantom-apis.test.js +0 -396
- package/dist/gates/promise-safety.test.d.ts +0 -1
- package/dist/gates/promise-safety.test.js +0 -34
- package/dist/gates/runner.test.d.ts +0 -1
- package/dist/gates/runner.test.js +0 -77
- package/dist/gates/scoped-gates.test.d.ts +0 -1
- package/dist/gates/scoped-gates.test.js +0 -52
- package/dist/gates/security-patterns-owasp.test.d.ts +0 -1
- package/dist/gates/security-patterns-owasp.test.js +0 -186
- package/dist/gates/security-patterns.test.d.ts +0 -1
- package/dist/gates/security-patterns.test.js +0 -194
- package/dist/gates/semantic-bugs.test.d.ts +0 -1
- package/dist/gates/semantic-bugs.test.js +0 -76
- package/dist/gates/side-effect-analysis.test.d.ts +0 -1
- package/dist/gates/side-effect-analysis.test.js +0 -162
- package/dist/gates/style-drift.test.d.ts +0 -1
- package/dist/gates/style-drift.test.js +0 -26
- package/dist/gates/test-quality.test.d.ts +0 -1
- package/dist/gates/test-quality.test.js +0 -325
- package/dist/gates/trusted-reviews.test.d.ts +0 -1
- package/dist/gates/trusted-reviews.test.js +0 -16
- package/dist/gates/unindexed-reads/queries.test.d.ts +0 -1
- package/dist/gates/unindexed-reads/queries.test.js +0 -54
- package/dist/gates/unindexed-reads/schema.test.d.ts +0 -1
- package/dist/gates/unindexed-reads/schema.test.js +0 -81
- package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +0 -1
- package/dist/gates/unindexed-reads/unindexed-reads.test.js +0 -90
- package/dist/hooks/checker.test.d.ts +0 -1
- package/dist/hooks/checker.test.js +0 -159
- package/dist/hooks/dlp-confidence.test.d.ts +0 -1
- package/dist/hooks/dlp-confidence.test.js +0 -51
- package/dist/hooks/dlp-feedback.test.d.ts +0 -1
- package/dist/hooks/dlp-feedback.test.js +0 -131
- package/dist/hooks/input-validator.test.d.ts +0 -1
- package/dist/hooks/input-validator.test.js +0 -329
- package/dist/hooks/templates.test.d.ts +0 -1
- package/dist/hooks/templates.test.js +0 -27
- package/dist/inference/brain-placeholder.test.d.ts +0 -1
- package/dist/inference/brain-placeholder.test.js +0 -28
- package/dist/inference/cloud-provider.test.d.ts +0 -1
- package/dist/inference/cloud-provider.test.js +0 -139
- package/dist/inference/executable.test.d.ts +0 -1
- package/dist/inference/executable.test.js +0 -41
- package/dist/inference/http-download.test.d.ts +0 -1
- package/dist/inference/http-download.test.js +0 -109
- package/dist/inference/llama-engine-checksum.test.d.ts +0 -1
- package/dist/inference/llama-engine-checksum.test.js +0 -27
- package/dist/inference/llama-engine.test.d.ts +0 -1
- package/dist/inference/llama-engine.test.js +0 -51
- package/dist/inference/llama-process.test.d.ts +0 -1
- package/dist/inference/llama-process.test.js +0 -61
- package/dist/inference/local-model.test.d.ts +0 -1
- package/dist/inference/local-model.test.js +0 -23
- package/dist/inference/model-download.test.d.ts +0 -1
- package/dist/inference/model-download.test.js +0 -125
- package/dist/inference/model-manager.test.d.ts +0 -1
- package/dist/inference/model-manager.test.js +0 -24
- package/dist/inference/types.test.d.ts +0 -1
- package/dist/inference/types.test.js +0 -19
- package/dist/memory/recall.test.d.ts +0 -1
- package/dist/memory/recall.test.js +0 -36
- package/dist/pattern-index/indexer.test.d.ts +0 -6
- package/dist/pattern-index/indexer.test.js +0 -197
- package/dist/pattern-index/matcher.test.d.ts +0 -6
- package/dist/pattern-index/matcher.test.js +0 -238
- package/dist/pattern-index/pattern-reuse.test.d.ts +0 -1
- package/dist/pattern-index/pattern-reuse.test.js +0 -76
- package/dist/pattern-index/semantic-runtime.test.d.ts +0 -1
- package/dist/pattern-index/semantic-runtime.test.js +0 -31
- package/dist/pattern-index/staleness.test.d.ts +0 -6
- package/dist/pattern-index/staleness.test.js +0 -211
- package/dist/review/agent-fixes.test.d.ts +0 -1
- package/dist/review/agent-fixes.test.js +0 -32
- package/dist/review/backtest-init.test.d.ts +0 -1
- package/dist/review/backtest-init.test.js +0 -98
- package/dist/review/backtest-judges.test.d.ts +0 -1
- package/dist/review/backtest-judges.test.js +0 -32
- package/dist/review/backtest-last.test.d.ts +0 -1
- package/dist/review/backtest-last.test.js +0 -109
- package/dist/review/backtest.test.d.ts +0 -1
- package/dist/review/backtest.test.js +0 -183
- package/dist/review/baseline.test.d.ts +0 -1
- package/dist/review/baseline.test.js +0 -22
- package/dist/review/branch-checks.test.d.ts +0 -1
- package/dist/review/branch-checks.test.js +0 -48
- package/dist/review/check-outcomes.test.d.ts +0 -1
- package/dist/review/check-outcomes.test.js +0 -56
- package/dist/review/code-patterns.test.d.ts +0 -1
- package/dist/review/code-patterns.test.js +0 -142
- package/dist/review/dead-code.test.d.ts +0 -1
- package/dist/review/dead-code.test.js +0 -154
- package/dist/review/deep-runs.test.d.ts +0 -1
- package/dist/review/deep-runs.test.js +0 -23
- package/dist/review/effectiveness.test.d.ts +0 -1
- package/dist/review/effectiveness.test.js +0 -42
- package/dist/review/fix-scope.test.d.ts +0 -1
- package/dist/review/fix-scope.test.js +0 -104
- package/dist/review/generated-files.test.d.ts +0 -1
- package/dist/review/generated-files.test.js +0 -25
- package/dist/review/migration-order.test.d.ts +0 -1
- package/dist/review/migration-order.test.js +0 -62
- package/dist/review/quiet.test.d.ts +0 -1
- package/dist/review/quiet.test.js +0 -38
- package/dist/review/receipt.test.d.ts +0 -1
- package/dist/review/receipt.test.js +0 -60
- package/dist/review/review-task.test.d.ts +0 -1
- package/dist/review/review-task.test.js +0 -89
- package/dist/review/review.test.d.ts +0 -1
- package/dist/review/review.test.js +0 -203
- package/dist/review/reviewer/adapters.test.d.ts +0 -1
- package/dist/review/reviewer/adapters.test.js +0 -60
- package/dist/review/reviewer/api-judge.test.d.ts +0 -1
- package/dist/review/reviewer/api-judge.test.js +0 -108
- package/dist/review/reviewer/background.test.d.ts +0 -1
- package/dist/review/reviewer/background.test.js +0 -118
- package/dist/review/reviewer/context.test.d.ts +0 -1
- package/dist/review/reviewer/context.test.js +0 -45
- package/dist/review/reviewer/exec.test.d.ts +0 -1
- package/dist/review/reviewer/exec.test.js +0 -59
- package/dist/review/reviewer/inputs.test.d.ts +0 -1
- package/dist/review/reviewer/inputs.test.js +0 -26
- package/dist/review/reviewer/panel.test.d.ts +0 -1
- package/dist/review/reviewer/panel.test.js +0 -110
- package/dist/review/reviewer/record.test.d.ts +0 -1
- package/dist/review/reviewer/record.test.js +0 -32
- package/dist/review/reviewer/rule-writer.test.d.ts +0 -1
- package/dist/review/reviewer/rule-writer.test.js +0 -52
- package/dist/review/reviewer/settings.test.d.ts +0 -1
- package/dist/review/reviewer/settings.test.js +0 -57
- package/dist/review/reviewer/usage.test.d.ts +0 -1
- package/dist/review/reviewer/usage.test.js +0 -14
- package/dist/review/reviewer.test.d.ts +0 -1
- package/dist/review/reviewer.test.js +0 -705
- package/dist/review/stories.test.d.ts +0 -1
- package/dist/review/stories.test.js +0 -58
- package/dist/review/toolchain.test.d.ts +0 -1
- package/dist/review/toolchain.test.js +0 -108
- package/dist/review/typed/redundancy.test.d.ts +0 -1
- package/dist/review/typed/redundancy.test.js +0 -315
- package/dist/review/typed/schema-nullability.test.d.ts +0 -1
- package/dist/review/typed/schema-nullability.test.js +0 -63
- package/dist/review-learning/human-edits.test.d.ts +0 -1
- package/dist/review-learning/human-edits.test.js +0 -47
- package/dist/review-learning/repo-rules.test.d.ts +0 -1
- package/dist/review-learning/repo-rules.test.js +0 -55
- package/dist/review-learning/review-learning.test.d.ts +0 -1
- package/dist/review-learning/review-learning.test.js +0 -249
- package/dist/safety.test.d.ts +0 -1
- package/dist/safety.test.js +0 -42
- package/dist/semantic/benchmark.test.d.ts +0 -1
- package/dist/semantic/benchmark.test.js +0 -22
- package/dist/semantic/intent/intent.test.d.ts +0 -1
- package/dist/semantic/intent/intent.test.js +0 -46
- package/dist/semantic/learn/learn.test.d.ts +0 -1
- package/dist/semantic/learn/learn.test.js +0 -67
- package/dist/semantic/origins.test.d.ts +0 -1
- package/dist/semantic/origins.test.js +0 -77
- package/dist/semantic/project-facts.test.d.ts +0 -1
- package/dist/semantic/project-facts.test.js +0 -45
- package/dist/semantic/sites/call-sites.test.d.ts +0 -1
- package/dist/semantic/sites/call-sites.test.js +0 -46
- package/dist/services/adaptive-thresholds.test.d.ts +0 -1
- package/dist/services/adaptive-thresholds.test.js +0 -53
- package/dist/services/agent-history.test.d.ts +0 -1
- package/dist/services/agent-history.test.js +0 -69
- package/dist/services/context-scope-summary.test.d.ts +0 -1
- package/dist/services/context-scope-summary.test.js +0 -17
- package/dist/services/context-telemetry-service.test.d.ts +0 -1
- package/dist/services/context-telemetry-service.test.js +0 -182
- package/dist/services/cursor-usage-sync.test.d.ts +0 -1
- package/dist/services/cursor-usage-sync.test.js +0 -173
- package/dist/services/engineering-knowledge-graph.test.d.ts +0 -1
- package/dist/services/engineering-knowledge-graph.test.js +0 -76
- package/dist/services/model-pricing.test.d.ts +0 -1
- package/dist/services/model-pricing.test.js +0 -44
- package/dist/services/observed-savings.test.d.ts +0 -1
- package/dist/services/observed-savings.test.js +0 -37
- package/dist/services/score-history.test.d.ts +0 -1
- package/dist/services/score-history.test.js +0 -61
- package/dist/smoke.test.d.ts +0 -1
- package/dist/smoke.test.js +0 -17
- package/dist/storage/cache-cleanup.test.d.ts +0 -1
- package/dist/storage/cache-cleanup.test.js +0 -54
- package/dist/storage/context-telemetry.test.d.ts +0 -1
- package/dist/storage/context-telemetry.test.js +0 -80
- package/dist/storage/db.test.d.ts +0 -1
- package/dist/storage/db.test.js +0 -46
- package/dist/storage/fix-lessons.test.d.ts +0 -1
- package/dist/storage/fix-lessons.test.js +0 -61
- package/dist/storage/lessons.test.d.ts +0 -1
- package/dist/storage/lessons.test.js +0 -81
- package/dist/storage/local-encryption.test.d.ts +0 -1
- package/dist/storage/local-encryption.test.js +0 -34
- package/dist/storage/local-memory.test.d.ts +0 -1
- package/dist/storage/local-memory.test.js +0 -55
- package/dist/storage/share-memory.test.d.ts +0 -1
- package/dist/storage/share-memory.test.js +0 -34
- package/dist/storage/team-diagnostics.test.d.ts +0 -1
- package/dist/storage/team-diagnostics.test.js +0 -51
- package/dist/storage/team-scope.test.d.ts +0 -1
- package/dist/storage/team-scope.test.js +0 -22
- package/dist/storage/team-store.test.d.ts +0 -1
- package/dist/storage/team-store.test.js +0 -11
- package/dist/storage/team-sync-scope.test.d.ts +0 -1
- package/dist/storage/team-sync-scope.test.js +0 -100
- package/dist/storage/team-vector-store.test.d.ts +0 -1
- package/dist/storage/team-vector-store.test.js +0 -56
- package/dist/storage/telemetry-scope.test.d.ts +0 -1
- package/dist/storage/telemetry-scope.test.js +0 -38
- package/dist/telemetry/telemetry.test.d.ts +0 -1
- package/dist/telemetry/telemetry.test.js +0 -64
- package/dist/types/index.test.d.ts +0 -1
- package/dist/types/index.test.js +0 -33
- package/dist/utils/command-line.test.d.ts +0 -1
- package/dist/utils/command-line.test.js +0 -8
- package/dist/utils/diff-removed.test.d.ts +0 -1
- package/dist/utils/diff-removed.test.js +0 -29
- package/dist/utils/diff.test.d.ts +0 -1
- package/dist/utils/diff.test.js +0 -38
- package/dist/utils/glob-paths.test.d.ts +0 -1
- package/dist/utils/glob-paths.test.js +0 -40
- package/dist/utils/profile.test.d.ts +0 -1
- package/dist/utils/profile.test.js +0 -68
- package/dist/utils/scanner.test.d.ts +0 -1
- package/dist/utils/scanner.test.js +0 -48
- package/dist/utils/scope.test.d.ts +0 -1
- package/dist/utils/scope.test.js +0 -38
|
@@ -12,6 +12,34 @@ import { createHash } from 'crypto';
|
|
|
12
12
|
import fs from 'fs';
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import { textSimilarity } from './consensus.js';
|
|
15
|
+
/** How alike a judge's prior point and a labelled line of the review must read to take the reviewer's label. */
|
|
16
|
+
const LABEL_SIMILARITY = 0.4;
|
|
17
|
+
/**
|
|
18
|
+
* The point with the severity its reviewer wrote, when the review labels it; the judge's reading otherwise. The reviewer
|
|
19
|
+
* is matched by login; the review by the date the judge names (judges write a timestamp, a date, or reformat it), and
|
|
20
|
+
* when no review of that reviewer has that date, by that reviewer's latest labelled review. A disagreement is said.
|
|
21
|
+
*/
|
|
22
|
+
function labelled(p, labels) {
|
|
23
|
+
const [login, ...rest] = (p.review ?? '').trim().split(/\s+/);
|
|
24
|
+
const day = /\d{4}-\d{2}-\d{2}/.exec(rest.join(' '))?.[0];
|
|
25
|
+
const theirs = login ? labels.filter(l => l.login === login) : [];
|
|
26
|
+
const sameDay = day ? theirs.filter(l => l.at.startsWith(day)) : [];
|
|
27
|
+
const latest = theirs.reduce((max, l) => (l.at > max ? l.at : max), '');
|
|
28
|
+
const candidates = sameDay.length ? sameDay : theirs.filter(l => l.at === latest);
|
|
29
|
+
const asItem = (issue) => ({ id: '', kind: 'prior', class: 'prior point', issue });
|
|
30
|
+
let best;
|
|
31
|
+
for (const label of candidates) {
|
|
32
|
+
const score = textSimilarity(asItem(p.point), asItem(label.text));
|
|
33
|
+
if (score >= LABEL_SIMILARITY && (!best || score > best.score))
|
|
34
|
+
best = { label, score };
|
|
35
|
+
}
|
|
36
|
+
if (!best)
|
|
37
|
+
return { point: p, took: false, disagreed: false };
|
|
38
|
+
if (best.label.severity === p.severity)
|
|
39
|
+
return { point: p, took: true, disagreed: false };
|
|
40
|
+
const note = `the review labels it ${best.label.severity}${p.severity ? `; the judge read ${p.severity}` : ''}`;
|
|
41
|
+
return { point: { ...p, severity: best.label.severity, evidence: p.evidence ? `${p.evidence}; ${note}` : note }, took: true, disagreed: true };
|
|
42
|
+
}
|
|
15
43
|
const NO_PRIOR_CHECKS = { approvals: [], inCheckout: () => undefined };
|
|
16
44
|
/** The touched lines of a unified diff (`git diff base...head`). */
|
|
17
45
|
export function changedLinesOf(diff) {
|
|
@@ -214,7 +242,10 @@ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS)
|
|
|
214
242
|
const notes = [];
|
|
215
243
|
const advisory = [];
|
|
216
244
|
const seen = new Set();
|
|
217
|
-
|
|
245
|
+
// The reviewer's own label wins over the judge's reading of it: a judge that calls a blocker a should-fix would demote it silently.
|
|
246
|
+
const read = verdict.prior_points.map(p => labelled(p, prior.labels ?? []));
|
|
247
|
+
const points = read.map(r => r.point);
|
|
248
|
+
const accepted = points.filter(p => p.severity === 'non-blocking');
|
|
218
249
|
// A should-fix is shown only when the judge could show it: a quote Rigour finds. One that cannot be checked is not a claim worth a person's time.
|
|
219
250
|
const advise = (item) => {
|
|
220
251
|
if (seen.has(item.id))
|
|
@@ -243,7 +274,7 @@ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS)
|
|
|
243
274
|
open.push(item);
|
|
244
275
|
};
|
|
245
276
|
const answerInReply = [];
|
|
246
|
-
for (const p of
|
|
277
|
+
for (const p of points) {
|
|
247
278
|
if (p.resolved)
|
|
248
279
|
continue;
|
|
249
280
|
if (p.severity === 'non-blocking') {
|
|
@@ -270,7 +301,11 @@ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS)
|
|
|
270
301
|
continue;
|
|
271
302
|
}
|
|
272
303
|
// Still open only where the judge quotes the code that keeps it open: a point a later commit already fixed never blocks.
|
|
273
|
-
|
|
304
|
+
// A should-fix keeps the tier its reviewer gave it: shown with its quote, never a block, like a should-fix finding.
|
|
305
|
+
if (p.severity === 'should-fix')
|
|
306
|
+
advise(item);
|
|
307
|
+
else
|
|
308
|
+
add(item);
|
|
274
309
|
}
|
|
275
310
|
for (const r of verdict.redundant) {
|
|
276
311
|
if (r.removed === false)
|
|
@@ -374,7 +409,7 @@ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS)
|
|
|
374
409
|
}
|
|
375
410
|
}
|
|
376
411
|
}
|
|
377
|
-
return { open: onePerRootCause(open), unverified, resolved, answerInReply, notes, advisory: onePerRootCause(advisory) };
|
|
412
|
+
return { open: onePerRootCause(open), unverified, resolved, answerInReply, notes, advisory: onePerRootCause(advisory), labels: { served: prior.labels?.length ?? 0, taken: read.filter(r => r.took).length, disagreed: read.filter(r => r.disagreed).length } };
|
|
378
413
|
}
|
|
379
414
|
/** How alike two items' words must be to be the same point made in two places; and, on the same lines, to be one point said two ways. */
|
|
380
415
|
const SAME_POINT = 0.6;
|
|
@@ -387,9 +422,14 @@ const SAME_LINES = 3;
|
|
|
387
422
|
function onePerRootCause(items) {
|
|
388
423
|
const kept = [];
|
|
389
424
|
for (const item of items) {
|
|
390
|
-
// The same class in the same words anywhere, or any two non-human items on the same lines that read alike (a rule break and
|
|
425
|
+
// The same class in the same words anywhere, or any two non-human items on the same lines that read alike (a rule break and
|
|
426
|
+
// the finding it caused). A human's point owns its lines: a rule break or finding there in like words is that point found
|
|
427
|
+
// again and folds into it; the human's words stay. One in other words is a different problem and stays its own item, so
|
|
428
|
+
// fixing the human's point does not leave it for the next round. Two human points never merge.
|
|
391
429
|
const nearby = (k) => !!k.file && k.file === item.file && k.line !== undefined && item.line !== undefined && Math.abs(k.line - item.line) <= SAME_LINES;
|
|
392
|
-
const same = item.kind === 'prior' ? undefined : kept.find(k => k.kind
|
|
430
|
+
const same = item.kind === 'prior' ? undefined : kept.find(k => k.kind === 'prior'
|
|
431
|
+
? nearby(k) && textSimilarity(k, item) >= SAME_PLACE
|
|
432
|
+
: (k.class === item.class && textSimilarity(k, item) >= SAME_POINT) || (nearby(k) && textSimilarity(k, item) >= SAME_PLACE));
|
|
393
433
|
if (!same) {
|
|
394
434
|
kept.push(item);
|
|
395
435
|
continue;
|
|
@@ -84,6 +84,8 @@ export interface ReviewerResult {
|
|
|
84
84
|
/** The latest human review it worked from (`<login>, <date> (<n> reviews)`). */
|
|
85
85
|
previousReview?: string;
|
|
86
86
|
pr?: number;
|
|
87
|
+
/** The pull request's title, when one was read. */
|
|
88
|
+
prTitle?: string;
|
|
87
89
|
}
|
|
88
90
|
export interface ModeRecord {
|
|
89
91
|
asked: 'single' | 'cross' | 'full' | 'panel';
|
package/dist/review/reviewer.js
CHANGED
|
@@ -36,6 +36,7 @@ import { buildContext, dismissedAs, readReviewDismissals, relatedDocs } from './
|
|
|
36
36
|
import { buildRecord } from './reviewer/record.js';
|
|
37
37
|
import { account, attachServedRules, changedLinesOf, checkoutSearch, checkoutVerifier, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
38
38
|
import { judgeUnset } from './reviewer/judge-env.js';
|
|
39
|
+
import { appendTaskEvent } from '../task/thread.js';
|
|
39
40
|
export { defaultExec, githubEnv, githubToken, parseJsonArrays } from './reviewer/exec.js';
|
|
40
41
|
export { itemLine } from './reviewer/verdict.js';
|
|
41
42
|
/** Findings and no verdict stop a push; a skipped review does not, and is reported as owed. */
|
|
@@ -48,7 +49,16 @@ const MAX_DELTA_LINES = 400;
|
|
|
48
49
|
/** The reviewer, and one anonymous usage event for what it did (only when the person opted in to telemetry). */
|
|
49
50
|
export async function runReviewer(cwd, base, config, exec = defaultExec, progress = message => process.stderr.write(`${message}\n`), options = {}) {
|
|
50
51
|
const result = await review(cwd, base, config, exec, progress, options);
|
|
51
|
-
|
|
52
|
+
const trigger = options.trigger ?? (options.reviewsBefore ? 'backtest' : 'review');
|
|
53
|
+
await trackUsage('reviewer_completed', reviewerUsage(result, trigger));
|
|
54
|
+
// On the task's thread, unless it replays history: a backtest's worktree is not anyone's task.
|
|
55
|
+
if (trigger !== 'backtest' && result.outcome !== 'skipped')
|
|
56
|
+
appendTaskEvent(cwd, {
|
|
57
|
+
kind: 'review', trigger, outcome: result.outcome, blocking: result.items.length, should_fix: result.advisory.length,
|
|
58
|
+
...(result.pr ? { pr: result.pr } : {}),
|
|
59
|
+
...(result.prTitle ? { pr_title: result.prTitle } : {}),
|
|
60
|
+
...(result.record ? { integrity: result.record.integrity, cost_usd: result.record.judges.reduce((sum, j) => sum + (j.cost_usd ?? 0), 0), judges: result.record.judges.map(j => j.reviewer) } : {}),
|
|
61
|
+
});
|
|
52
62
|
return result;
|
|
53
63
|
}
|
|
54
64
|
async function review(cwd, base, config, exec, progress, options) {
|
|
@@ -107,7 +117,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
107
117
|
if (skip)
|
|
108
118
|
return none('skipped', skip, { reviewers, pr: pr?.number });
|
|
109
119
|
}
|
|
110
|
-
let reviews = { markdown: 'none\n', key: '', count: 0, approvals: [] };
|
|
120
|
+
let reviews = { markdown: 'none\n', key: '', count: 0, approvals: [], labels: [] };
|
|
111
121
|
if (gh && pr) {
|
|
112
122
|
const read = await humanReviews(gh, pr, options.reviewsBefore);
|
|
113
123
|
if (read.error)
|
|
@@ -187,12 +197,17 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
187
197
|
const openFile = store.openPath(verdictFile);
|
|
188
198
|
const previousOpen = scope === 'delta' ? store.readJson(store.openPath(previous.verdict)) ?? [] : undefined;
|
|
189
199
|
const verify = checkoutVerifier(cwd);
|
|
190
|
-
const prior = { approvals: reviews.approvals, inCheckout: checkoutSearch(cwd), changed: changedLinesOf(fullDiff) };
|
|
200
|
+
const prior = { approvals: reviews.approvals, inCheckout: checkoutSearch(cwd), changed: changedLinesOf(fullDiff), labels: reviews.labels };
|
|
191
201
|
const modelFor = (name) => settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
|
|
192
202
|
// The record of the review, written beside the verdict once and rebuilt from the same verdict on a cached read.
|
|
203
|
+
// What a judge reads besides the repository and Rigour's inputs, on this machine: on the record, so "the same judge" is a claim it can check.
|
|
204
|
+
const outsideOf = (reviewer) => {
|
|
205
|
+
const outside = ADAPTERS[reviewer]?.outsideRepo?.(os.homedir(), installed.get(reviewer)?.version);
|
|
206
|
+
return outside ? { outside_repo: outside } : {};
|
|
207
|
+
};
|
|
193
208
|
const withRecord = (accounted, verdict, cached) => {
|
|
194
209
|
const res = result(accounted, verdict, reviewers, scope, why, cached, reviews, pr, modeRecord, settings.dismissals);
|
|
195
|
-
const judges = (verdict.reviewers ?? []).map(r => ({ reviewer: r.reviewer, ...(installed.get(r.reviewer)?.version ? { version: installed.get(r.reviewer).version } : {}), ...(modelFor(r.reviewer) ? { model: modelFor(r.reviewer) } : {}), ...(typeof r.cost_usd === 'number' ? { cost_usd: r.cost_usd } : {}), ...(r.trace?.turns ? { turns: r.trace.turns } : {}) }));
|
|
210
|
+
const judges = (verdict.reviewers ?? []).map(r => ({ reviewer: r.reviewer, ...(installed.get(r.reviewer)?.version ? { version: installed.get(r.reviewer).version } : {}), ...(modelFor(r.reviewer) ? { model: modelFor(r.reviewer) } : {}), ...(typeof r.cost_usd === 'number' ? { cost_usd: r.cost_usd } : {}), ...(r.trace?.turns ? { turns: r.trace.turns } : {}), ...outsideOf(r.reviewer) }));
|
|
196
211
|
const recordPath = store.recordPath(verdictFile);
|
|
197
212
|
const record = (cached && store.readJson(recordPath)) || buildRecord({ head, base: baseSha, scope, verdict, accounted, judges, lessonsServed: context.lessons, humanReviews: reviews.count });
|
|
198
213
|
if (!cached || !fs.existsSync(recordPath))
|
|
@@ -212,7 +227,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
212
227
|
let inlineInputs = [];
|
|
213
228
|
const runJudge = (name, prompt, model) => name === 'api'
|
|
214
229
|
? runApiJudge(prompt, { url: settings.api.url, model: settings.api.model, key: process.env[settings.api.key_env] ?? '', maxTurns: settings.api.max_turns, timeoutMs: settings.timeout_ms, cwd, roots: [cwd, work], inputs: inlineInputs, ...(settings.reasoning[name] ? { reasoning: settings.reasoning[name] } : {}), ...(options.fetch ? { fetchImpl: options.fetch } : {}) })
|
|
215
|
-
: exec(installed.get(name).binary, ADAPTERS[name].args(prompt, model, { reasoning: settings.reasoning[name] }), { cwd, timeoutMs: settings.timeout_ms, unset: judgeUnset(name, settings.judge_env) });
|
|
230
|
+
: exec(installed.get(name).binary, ADAPTERS[name].args(prompt, model, { reasoning: settings.reasoning[name] }), { cwd, timeoutMs: settings.timeout_ms, unset: judgeUnset(name, settings.judge_env), ...(ADAPTERS[name].env ? { env: ADAPTERS[name].env } : {}) });
|
|
216
231
|
try {
|
|
217
232
|
const file = (name, text) => {
|
|
218
233
|
const target = path.join(work, name);
|
|
@@ -336,6 +351,9 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
336
351
|
verdict = { ...verdict, panel: { judgeItemIds: judgeItems.flat().map(item => item.id), items }, reviewers: [...(verdict.reviewers ?? []), ...cross] };
|
|
337
352
|
}
|
|
338
353
|
const accounted = decide(verdict, previousOpen, verify, prior, dismissals);
|
|
354
|
+
// Said every run where the reviews carry labels, so a matcher that takes none is visible rather than silent.
|
|
355
|
+
if (accounted.labels?.served)
|
|
356
|
+
progress(`Rigour reviewer: ${accounted.labels.taken} of ${verdict.prior_points.length} prior point(s) took the review's own severity label (${accounted.labels.served} labelled line(s)); the judge read ${accounted.labels.disagreed} otherwise`);
|
|
339
357
|
store.writeJson(verdictFile, { ...verdict, inputs: { head, base: baseSha, scope, why, mode: modeRecord, reviewers, versions: reviewerVersions, authors: [...authors], fingerprint, human_reviews: reviews.count, reviews_before: options.reviewsBefore ?? null, since: previous?.head ?? null, at: new Date().toISOString() } });
|
|
340
358
|
store.writeJson(openFile, accounted.open);
|
|
341
359
|
store.writeJson(store.decidedPath(verdictFile), accounted);
|
|
@@ -462,5 +480,6 @@ function result(accounted, verdict, reviewers, scope, why, cached, reviews, pr,
|
|
|
462
480
|
cached,
|
|
463
481
|
...(reviews.label ? { previousReview: reviews.label } : {}),
|
|
464
482
|
...(pr ? { pr: pr.number } : {}),
|
|
483
|
+
...(pr?.title ? { prTitle: pr.title } : {}),
|
|
465
484
|
};
|
|
466
485
|
}
|
|
@@ -5,14 +5,26 @@ export interface RepoRule {
|
|
|
5
5
|
text: string;
|
|
6
6
|
/** Paths the rule names (files or directories). */
|
|
7
7
|
paths: string[];
|
|
8
|
+
/** Where the rule applies: the folder of the nested rules file it came from (`services/billing/`); the whole repository when absent. */
|
|
9
|
+
scope?: string;
|
|
8
10
|
/** Identifiers the rule names. */
|
|
9
11
|
symbols: string[];
|
|
10
12
|
/** Worded as a requirement: a break can block. Guidance otherwise: a break is shown. */
|
|
11
13
|
requirement: boolean;
|
|
12
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Every rule file of the repository: the root ones, the AGENTS.md and CLAUDE.md files in folders below it (outside
|
|
17
|
+
* vendored folders), the rule directories, and every file a rule file imports with an `@path` line (relative to the
|
|
18
|
+
* importing file, inside the repository). A judge does not load these by itself; this is how the repository's rules
|
|
19
|
+
* reach it. A nested file's rules apply to its own folder only, and so do the rules of the files it imports.
|
|
20
|
+
*/
|
|
13
21
|
export declare function readRepoRules(cwd: string): RepoRule[];
|
|
14
22
|
/** One rule per top-level bullet or paragraph; headings and import lines are not rules. */
|
|
15
23
|
export declare function splitRules(source: string, text: string): RepoRule[];
|
|
16
24
|
export declare function rulesSection(rules: RepoRule[]): string;
|
|
17
|
-
/**
|
|
18
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The rules that apply to a diff's changed files and added identifiers, the top `limit`. `namedOnly`: only rules that
|
|
27
|
+
* name a path or identifier the change touches (a briefing has no code to judge applicability against, so a rule that
|
|
28
|
+
* merely shares words with a large change is noise there).
|
|
29
|
+
*/
|
|
30
|
+
export declare function rulesForDiff(cwd: string, diff: string, enabled?: boolean, limit?: number, namedOnly?: boolean): RepoRule[];
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* is better served by the few rules that name a file or identifier the
|
|
9
9
|
* change actually touches.
|
|
10
10
|
*/
|
|
11
|
+
import { spawnSync } from 'child_process';
|
|
11
12
|
import fs from 'fs';
|
|
12
13
|
import path from 'path';
|
|
13
14
|
import crypto from 'crypto';
|
|
@@ -20,12 +21,55 @@ const MAX_RULES = 5;
|
|
|
20
21
|
const CONTINUES = /^\**\s*(why|how to apply|example|examples|evidence|exception|exceptions|fix|note)\b\s*:?\**\s*:?/i;
|
|
21
22
|
/** Worded as a requirement: the team said must, never, always, only, every, do not. A rule without these is guidance. */
|
|
22
23
|
const REQUIREMENT = /\b(must|never|always|only|every|do not|don't|forbidden|required|non-negotiable)\b/i;
|
|
24
|
+
/** The most rule files read, imports included: a loop or a sprawling import tree stops here. */
|
|
25
|
+
const MAX_RULE_SOURCES = 50;
|
|
26
|
+
/** Folders of someone else's code a repository carries: a rules file in one is that project's, not this team's. */
|
|
27
|
+
const VENDORED = /(^|\/)(vendor|vendors|third_party|third-party|node_modules|bower_components|external|externals)\//;
|
|
28
|
+
/**
|
|
29
|
+
* Every rule file of the repository: the root ones, the AGENTS.md and CLAUDE.md files in folders below it (outside
|
|
30
|
+
* vendored folders), the rule directories, and every file a rule file imports with an `@path` line (relative to the
|
|
31
|
+
* importing file, inside the repository). A judge does not load these by itself; this is how the repository's rules
|
|
32
|
+
* reach it. A nested file's rules apply to its own folder only, and so do the rules of the files it imports.
|
|
33
|
+
*/
|
|
23
34
|
export function readRepoRules(cwd) {
|
|
24
|
-
const
|
|
25
|
-
...RULE_FILES.filter(f => fs.existsSync(path.join(cwd, f))),
|
|
26
|
-
...
|
|
35
|
+
const queue = [
|
|
36
|
+
...RULE_FILES.filter(f => fs.existsSync(path.join(cwd, f))).map(file => ({ file })),
|
|
37
|
+
...nestedRuleFiles(cwd).map(file => ({ file, scope: `${path.posix.dirname(file)}/` })),
|
|
38
|
+
...RULE_DIRS.flatMap(dir => listRuleFiles(cwd, dir)).map(file => ({ file })),
|
|
27
39
|
];
|
|
28
|
-
|
|
40
|
+
const read = new Set();
|
|
41
|
+
const rules = [];
|
|
42
|
+
while (queue.length && read.size < MAX_RULE_SOURCES) {
|
|
43
|
+
const { file, scope } = queue.shift();
|
|
44
|
+
// The same file imported from two places is read once, with the scope it was first reached with: a root import first.
|
|
45
|
+
if (read.has(file))
|
|
46
|
+
continue;
|
|
47
|
+
read.add(file);
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = fs.readFileSync(path.join(cwd, file), 'utf8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
rules.push(...splitRules(file, text).map(rule => (scope ? { ...rule, scope } : rule)));
|
|
56
|
+
for (const m of text.matchAll(/^@(\S+)\s*$/gm)) {
|
|
57
|
+
const target = path.posix.normalize(path.posix.join(path.posix.dirname(file), m[1].replace(/^\.\//, '')));
|
|
58
|
+
if (target.startsWith('..') || path.isAbsolute(m[1]) || m[1].startsWith('~') || !fs.existsSync(path.join(cwd, target)))
|
|
59
|
+
continue;
|
|
60
|
+
// Root imports go to the front, so a file both a root and a nested file import keeps the repository-wide scope.
|
|
61
|
+
if (scope)
|
|
62
|
+
queue.push({ file: target, scope });
|
|
63
|
+
else
|
|
64
|
+
queue.unshift({ file: target });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return rules;
|
|
68
|
+
}
|
|
69
|
+
/** AGENTS.md and CLAUDE.md in folders below the root, as git tracks them, outside vendored folders. */
|
|
70
|
+
function nestedRuleFiles(cwd) {
|
|
71
|
+
const listed = spawnSync('git', ['ls-files', '-z', '--', '*/AGENTS.md', '*/CLAUDE.md'], { cwd, encoding: 'utf8', timeout: 5000 });
|
|
72
|
+
return listed.status === 0 ? listed.stdout.split('\0').filter(f => f && !VENDORED.test(f)) : [];
|
|
29
73
|
}
|
|
30
74
|
/** One rule per top-level bullet or paragraph; headings and import lines are not rules. */
|
|
31
75
|
export function splitRules(source, text) {
|
|
@@ -74,7 +118,9 @@ export function splitRules(source, text) {
|
|
|
74
118
|
* not filtering: on a large change most rules share some words, so the judge decides applicability
|
|
75
119
|
* rule by rule from the top `limit`.
|
|
76
120
|
*/
|
|
77
|
-
function rulesForChange(rules, files, symbols, limit = MAX_RULES) {
|
|
121
|
+
function rulesForChange(rules, files, symbols, limit = MAX_RULES, namedOnly = false) {
|
|
122
|
+
// A folder's own rules are served only when the change touches that folder: never checked, so never broken, elsewhere.
|
|
123
|
+
rules = rules.filter(rule => !rule.scope || files.some(f => f.startsWith(rule.scope)));
|
|
78
124
|
const changeWords = new Set([...files.flatMap(f => f.split(/[/._-]+/)), ...symbols].flatMap(meaningfulWords));
|
|
79
125
|
const scored = rules.map(rule => {
|
|
80
126
|
const pathHits = rule.paths.filter(p => files.some(f => f === p || f.startsWith(p.endsWith('/') ? p : `${p}/`) || f.endsWith(`/${p}`))).length;
|
|
@@ -82,7 +128,7 @@ function rulesForChange(rules, files, symbols, limit = MAX_RULES) {
|
|
|
82
128
|
const shared = new Set(meaningfulWords(rule.text).filter(w => changeWords.has(w))).size;
|
|
83
129
|
return { rule, named: 3 * pathHits + 2 * symbolHits, shared };
|
|
84
130
|
});
|
|
85
|
-
return scored.filter(s => s.named > 0 || s.shared >= 2).sort((a, b) => b.named - a.named || b.shared - a.shared).slice(0, limit).map(s => s.rule);
|
|
131
|
+
return scored.filter(s => s.named > 0 || (!namedOnly && s.shared >= 2)).sort((a, b) => b.named - a.named || b.shared - a.shared).slice(0, limit).map(s => s.rule);
|
|
86
132
|
}
|
|
87
133
|
export function rulesSection(rules) {
|
|
88
134
|
if (rules.length === 0)
|
|
@@ -97,11 +143,15 @@ function listRuleFiles(cwd, dir) {
|
|
|
97
143
|
return [];
|
|
98
144
|
}
|
|
99
145
|
}
|
|
100
|
-
/**
|
|
101
|
-
|
|
146
|
+
/**
|
|
147
|
+
* The rules that apply to a diff's changed files and added identifiers, the top `limit`. `namedOnly`: only rules that
|
|
148
|
+
* name a path or identifier the change touches (a briefing has no code to judge applicability against, so a rule that
|
|
149
|
+
* merely shares words with a large change is noise there).
|
|
150
|
+
*/
|
|
151
|
+
export function rulesForDiff(cwd, diff, enabled = false, limit = MAX_RULES, namedOnly = false) {
|
|
102
152
|
if (!enabled)
|
|
103
153
|
return [];
|
|
104
154
|
const files = [...diff.matchAll(/^\+\+\+ b\/(.+)$/gm)].map(m => m[1].trim());
|
|
105
155
|
const added = diff.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).join('\n');
|
|
106
|
-
return rulesForChange(readRepoRules(cwd), files, new Set(added.match(/[A-Za-z_$][\w$]*/g) ?? []), limit);
|
|
156
|
+
return rulesForChange(readRepoRules(cwd), files, new Set(added.match(/[A-Za-z_$][\w$]*/g) ?? []), limit, namedOnly);
|
|
107
157
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Under the repository's git folder (the common one, shared by every worktree). */
|
|
2
|
+
export declare const THREADS_DIR = "rigour/threads";
|
|
3
|
+
export type TaskEventKind = 'edit-check' | 'stop-review' | 'push' | 'review' | 'brief';
|
|
4
|
+
export interface TaskEvent {
|
|
5
|
+
kind: TaskEventKind;
|
|
6
|
+
/** The agent session it happened in, when a hook knows it. */
|
|
7
|
+
session?: string;
|
|
8
|
+
/** The agent (claude, cursor, codex, ...) or `person`, when known. */
|
|
9
|
+
agent?: string;
|
|
10
|
+
/** The pull request, once a review of it ran. */
|
|
11
|
+
pr?: number;
|
|
12
|
+
/** What happened, in a few fields; each kind documents its own. */
|
|
13
|
+
[field: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface ThreadEvent extends TaskEvent {
|
|
16
|
+
at: string;
|
|
17
|
+
task: string;
|
|
18
|
+
branch: string;
|
|
19
|
+
head?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The task the checkout is working on: the ticket its branch names when one of the branch's own commit subjects names
|
|
23
|
+
* it too, else the branch; undefined on a detached head.
|
|
24
|
+
*/
|
|
25
|
+
export declare function taskOf(cwd: string): {
|
|
26
|
+
key: string;
|
|
27
|
+
branch: string;
|
|
28
|
+
head?: string;
|
|
29
|
+
} | undefined;
|
|
30
|
+
/** Appends one event to the checkout's task thread. Best effort: a thread never breaks the hook or command it runs in. */
|
|
31
|
+
export declare function appendTaskEvent(cwd: string, event: TaskEvent): ThreadEvent | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The thread for a key, oldest first: a ticket (`PROJ-123`, every branch whose events carry it, from their first
|
|
34
|
+
* event), a branch name, a pull request (`#42` or `42`: the branches a review of it ran on), or nothing for the
|
|
35
|
+
* checkout's own task. Matching is on the keys stored in the events, never on file names. Unreadable lines are skipped.
|
|
36
|
+
*/
|
|
37
|
+
export declare function readThread(cwd: string, key?: string): {
|
|
38
|
+
task: string;
|
|
39
|
+
events: ThreadEvent[];
|
|
40
|
+
} | undefined;
|
|
41
|
+
/** The thread as a person reads it: who worked on it, what was caught and fixed, what blocked, then the events in order. */
|
|
42
|
+
export declare function threadText(thread: {
|
|
43
|
+
task: string;
|
|
44
|
+
events: ThreadEvent[];
|
|
45
|
+
}): string[];
|
|
46
|
+
/** Where the repository's threads are: its common git folder, so every worktree of it writes to the same threads. */
|
|
47
|
+
export declare function threadsDir(cwd: string): string | undefined;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engineering task: one piece of work, whatever agents and people touch it, and its thread, everything that
|
|
3
|
+
* happened to it in order. Events are kept per branch, one file each (`<git dir>/rigour/threads/<branch>-<hash>.jsonl`,
|
|
4
|
+
* the hash of the exact branch name, so no two branches share a file). The task is the ticket the branch names when
|
|
5
|
+
* the branch's own commit subjects, or the title of its pull request as a review recorded it, write it as a ticket
|
|
6
|
+
* (`PROJ-123`): a version token in a branch name (`pin-node-22`, `utf-8`) is not a ticket. Otherwise the task is the
|
|
7
|
+
* branch. The task is worked out once per commit and kept: the edit hook runs on every edit. Reading a ticket gathers every branch that worked on it;
|
|
8
|
+
* a pull request joins when a review of it runs. Writers only append, and never fail the hook or command they run in.
|
|
9
|
+
* The files live in the repository's common git folder, shared by its worktrees; nothing enters the working tree.
|
|
10
|
+
*
|
|
11
|
+
* What a thread holds is what Rigour saw: hooked agent sessions (the edit check, the stop review), the push gate, and
|
|
12
|
+
* reviews. Work done where no hook ran (an agent without hooks, a person in an editor) shows only through the commits
|
|
13
|
+
* and reviews that follow it.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'child_process';
|
|
16
|
+
import { createHash } from 'crypto';
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
/** Under the repository's git folder (the common one, shared by every worktree). */
|
|
20
|
+
export const THREADS_DIR = 'rigour/threads';
|
|
21
|
+
/** A ticket key in a branch name: `feat/proj-123-thing` → `PROJ-123`. */
|
|
22
|
+
const TICKET = /(?:^|[^A-Za-z0-9])([A-Za-z][A-Za-z0-9]{1,9}-\d{1,7})(?=$|[^0-9])/;
|
|
23
|
+
/**
|
|
24
|
+
* The task the checkout is working on: the ticket its branch names when one of the branch's own commit subjects names
|
|
25
|
+
* it too, else the branch; undefined on a detached head.
|
|
26
|
+
*/
|
|
27
|
+
export function taskOf(cwd) {
|
|
28
|
+
const task = taskIn(cwd);
|
|
29
|
+
return task ? { key: task.key, branch: task.branch, ...(task.head ? { head: task.head } : {}) } : undefined;
|
|
30
|
+
}
|
|
31
|
+
/** The task and the folder its thread is in, with one lookup of each: the edit hook pays for this on every edit. */
|
|
32
|
+
function taskIn(cwd) {
|
|
33
|
+
const branch = git(cwd, ['symbolic-ref', '--short', '-q', 'HEAD']); // a branch with no commit yet has a task too
|
|
34
|
+
if (!branch)
|
|
35
|
+
return undefined;
|
|
36
|
+
const head = git(cwd, ['rev-parse', '-q', '--verify', 'HEAD']);
|
|
37
|
+
const dir = threadsDir(cwd);
|
|
38
|
+
const cache = dir ? path.join(dir, '..', 'task-cache.json') : undefined;
|
|
39
|
+
const cached = cache ? readJson(cache)[branch] : undefined;
|
|
40
|
+
if (cached && cached.head === (head ?? '') && typeof cached.key === 'string')
|
|
41
|
+
return { key: cached.key, branch, ...(head ? { head } : {}), ...(dir ? { dir } : {}) };
|
|
42
|
+
const ticket = TICKET.exec(branch)?.[1]?.toUpperCase();
|
|
43
|
+
// Written as a ticket (upper case, as trackers print it): `node-22` in a subject is a version, not a ticket.
|
|
44
|
+
const written = ticket ? new RegExp(`(^|[^A-Za-z0-9])${ticket.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![0-9])`) : undefined;
|
|
45
|
+
const titles = dir ? readEvents(path.join(dir, branchFile(branch))).map(e => e.pr_title).filter((t) => typeof t === 'string') : [];
|
|
46
|
+
const confirmed = !!written && [...titles, ...branchSubjects(cwd)].some(text => written.test(text));
|
|
47
|
+
const key = confirmed ? ticket : `branch:${branch}`;
|
|
48
|
+
if (cache)
|
|
49
|
+
writeCache(cache, branch, { head: head ?? '', key });
|
|
50
|
+
return { key, branch, ...(head ? { head } : {}), ...(dir ? { dir } : {}) };
|
|
51
|
+
}
|
|
52
|
+
function readJson(file) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
55
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** The task worked out for a branch at a commit; `undefined` forgets it (a new pull request title may confirm a ticket). */
|
|
62
|
+
function writeCache(file, branch, entry) {
|
|
63
|
+
try {
|
|
64
|
+
const all = readJson(file);
|
|
65
|
+
if (entry)
|
|
66
|
+
all[branch] = entry;
|
|
67
|
+
else
|
|
68
|
+
delete all[branch];
|
|
69
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
70
|
+
fs.writeFileSync(file, JSON.stringify(all));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// a cache that cannot be written is worked out again next time
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** The subjects of the branch's own commits: since it left the main branch, or its last 50 when no main branch is found. */
|
|
77
|
+
function branchSubjects(cwd) {
|
|
78
|
+
const main = ['origin/main', 'main', 'origin/master', 'master'].find(ref => git(cwd, ['rev-parse', '--verify', '-q', ref]) !== undefined);
|
|
79
|
+
const base = main ? git(cwd, ['merge-base', 'HEAD', main]) : undefined;
|
|
80
|
+
const range = base && base !== git(cwd, ['rev-parse', 'HEAD']) ? [`${base}..HEAD`] : ['-50', 'HEAD'];
|
|
81
|
+
return (git(cwd, ['log', '--format=%s', ...range]) ?? '').split('\n').filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
/** Appends one event to the checkout's task thread. Best effort: a thread never breaks the hook or command it runs in. */
|
|
84
|
+
export function appendTaskEvent(cwd, event) {
|
|
85
|
+
try {
|
|
86
|
+
const task = taskIn(cwd);
|
|
87
|
+
if (!task?.dir)
|
|
88
|
+
return undefined;
|
|
89
|
+
const dir = task.dir;
|
|
90
|
+
const line = { at: new Date().toISOString(), task: task.key, branch: task.branch, ...(task.head ? { head: task.head } : {}), ...event };
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
fs.appendFileSync(path.join(dir, branchFile(task.branch)), JSON.stringify(line) + '\n');
|
|
93
|
+
// A pull request title can confirm the branch's ticket: work the task out again next time.
|
|
94
|
+
if (typeof event.pr_title === 'string' && task.key.startsWith('branch:'))
|
|
95
|
+
writeCache(path.join(dir, '..', 'task-cache.json'), task.branch, undefined);
|
|
96
|
+
return line;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The thread for a key, oldest first: a ticket (`PROJ-123`, every branch whose events carry it, from their first
|
|
104
|
+
* event), a branch name, a pull request (`#42` or `42`: the branches a review of it ran on), or nothing for the
|
|
105
|
+
* checkout's own task. Matching is on the keys stored in the events, never on file names. Unreadable lines are skipped.
|
|
106
|
+
*/
|
|
107
|
+
export function readThread(cwd, key) {
|
|
108
|
+
const dir = threadsDir(cwd);
|
|
109
|
+
if (!dir)
|
|
110
|
+
return undefined;
|
|
111
|
+
const wanted = key?.trim();
|
|
112
|
+
if (!wanted) {
|
|
113
|
+
const own = taskOf(cwd);
|
|
114
|
+
if (!own)
|
|
115
|
+
return undefined;
|
|
116
|
+
return own.key.startsWith('branch:') ? { task: own.key, events: readEvents(path.join(dir, branchFile(own.branch))).filter(e => e.branch === own.branch) } : byTask(dir, own.key) ?? { task: own.key, events: [] };
|
|
117
|
+
}
|
|
118
|
+
const pr = /^#?(\d+)$/.exec(wanted)?.[1];
|
|
119
|
+
if (pr) {
|
|
120
|
+
const found = gather(dir, `#${pr}`, events => events.some(e => e.pr === Number(pr)));
|
|
121
|
+
return found ? { task: found.events[found.events.length - 1].task, events: found.events } : undefined;
|
|
122
|
+
}
|
|
123
|
+
const branch = wanted.replace(/^branch:/, '');
|
|
124
|
+
const own = readEvents(path.join(dir, branchFile(branch))).filter(e => e.branch === branch);
|
|
125
|
+
if (own.length)
|
|
126
|
+
return { task: own[own.length - 1].task, events: own };
|
|
127
|
+
return byTask(dir, wanted.toUpperCase());
|
|
128
|
+
}
|
|
129
|
+
/** Every branch whose events name the task, whole, as one thread. */
|
|
130
|
+
function byTask(dir, task) {
|
|
131
|
+
return gather(dir, task, events => events.some(e => e.task === task));
|
|
132
|
+
}
|
|
133
|
+
function gather(dir, task, wanted) {
|
|
134
|
+
const events = listThreads(dir).map(file => readEvents(path.join(dir, file))).filter(wanted).flat().sort(byTime);
|
|
135
|
+
return events.length ? { task, events } : undefined;
|
|
136
|
+
}
|
|
137
|
+
/** The thread as a person reads it: who worked on it, what was caught and fixed, what blocked, then the events in order. */
|
|
138
|
+
export function threadText(thread) {
|
|
139
|
+
const { task, events } = thread;
|
|
140
|
+
if (events.length === 0)
|
|
141
|
+
return [`${task}: nothing recorded yet`];
|
|
142
|
+
const sessions = new Set(events.map(e => e.session).filter(Boolean));
|
|
143
|
+
const agents = [...new Set(events.map(e => e.agent).filter((a) => typeof a === 'string'))];
|
|
144
|
+
const prs = [...new Set(events.map(e => e.pr).filter((p) => typeof p === 'number'))];
|
|
145
|
+
const branches = [...new Set(events.map(e => e.branch))];
|
|
146
|
+
const checks = events.filter(e => e.kind === 'edit-check');
|
|
147
|
+
const caught = checks.reduce((n, e) => n + num(e.findings), 0);
|
|
148
|
+
const lines = [
|
|
149
|
+
`${task} · ${branches.join(', ')}${prs.length ? ` · PR ${prs.map(p => `#${p}`).join(', ')}` : ''}`,
|
|
150
|
+
`${events[0].at} → ${events[events.length - 1].at} · ${sessions.size} agent session(s)${agents.length ? ` (${agents.join(', ')})` : ''}`,
|
|
151
|
+
`edit checks: ${checks.length}, findings caught while writing: ${caught}${caught ? `, files clean again after a finding: ${cleanedAfter(checks)}` : ''}`,
|
|
152
|
+
];
|
|
153
|
+
const stops = events.filter(e => e.kind === 'stop-review');
|
|
154
|
+
if (stops.length)
|
|
155
|
+
lines.push(`stop reviews: ${stops.length}, blocked: ${stops.filter(e => e.blocked).length}`);
|
|
156
|
+
const pushes = events.filter(e => e.kind === 'push');
|
|
157
|
+
if (pushes.length)
|
|
158
|
+
lines.push(`pushes: ${pushes.length}, blocked: ${pushes.filter(e => e.passed === false).length}`);
|
|
159
|
+
const reviews = events.filter(e => e.kind === 'review');
|
|
160
|
+
if (reviews.length)
|
|
161
|
+
lines.push(`reviews: ${reviews.length}, last: ${String(reviews[reviews.length - 1].outcome)} with ${num(reviews[reviews.length - 1].blocking)} blocking${typeof reviews[reviews.length - 1].integrity === 'string' ? ` (record ${String(reviews[reviews.length - 1].integrity).slice(0, 16)})` : ''}`);
|
|
162
|
+
lines.push('');
|
|
163
|
+
for (const e of events)
|
|
164
|
+
lines.push(` ${e.at.slice(0, 19).replace('T', ' ')} ${e.kind.padEnd(11)} ${describe(e)}`);
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
/** Files that had a finding at one edit check and none at a later one: caught while writing, fixed before push. */
|
|
168
|
+
function cleanedAfter(checks) {
|
|
169
|
+
const flagged = new Set();
|
|
170
|
+
const cleaned = new Set();
|
|
171
|
+
for (const e of checks) {
|
|
172
|
+
const files = Array.isArray(e.files) ? e.files.filter((f) => typeof f === 'string') : [];
|
|
173
|
+
if (num(e.findings) > 0)
|
|
174
|
+
files.forEach(f => flagged.add(f));
|
|
175
|
+
else
|
|
176
|
+
files.filter(f => flagged.has(f)).forEach(f => cleaned.add(f));
|
|
177
|
+
}
|
|
178
|
+
return cleaned.size;
|
|
179
|
+
}
|
|
180
|
+
function describe(e) {
|
|
181
|
+
const who = [e.agent, e.session ? `session ${String(e.session).slice(0, 8)}` : undefined].filter(Boolean).join(', ');
|
|
182
|
+
const files = Array.isArray(e.files) ? `${e.files.length} file(s)` : '';
|
|
183
|
+
const what = e.kind === 'edit-check' ? `${files}, ${num(e.findings)} finding(s)`
|
|
184
|
+
: e.kind === 'stop-review' ? (e.blocked ? `blocked: ${num(e.blocking)} to fix` : 'passed')
|
|
185
|
+
: e.kind === 'push' ? (e.passed === false ? `blocked: ${num(e.failed)} check(s) failed` : 'passed')
|
|
186
|
+
: e.kind === 'review' ? `${String(e.outcome)}, ${num(e.blocking)} blocking, ${num(e.should_fix)} should-fix${e.pr ? ` on #${e.pr}` : ''}`
|
|
187
|
+
: e.kind === 'brief' ? `${num(e.items)} item(s) briefed` : '';
|
|
188
|
+
return [what, who ? `(${who})` : '', e.head ? `@${String(e.head).slice(0, 9)}` : ''].filter(Boolean).join(' ');
|
|
189
|
+
}
|
|
190
|
+
function readEvents(file) {
|
|
191
|
+
let text;
|
|
192
|
+
try {
|
|
193
|
+
text = fs.readFileSync(file, 'utf8');
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
return text.split('\n').filter(Boolean).flatMap(line => {
|
|
199
|
+
try {
|
|
200
|
+
const e = JSON.parse(line);
|
|
201
|
+
return e && typeof e.at === 'string' && typeof e.task === 'string' && typeof e.kind === 'string' ? [e] : [];
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
}).sort(byTime);
|
|
207
|
+
}
|
|
208
|
+
/** Where the repository's threads are: its common git folder, so every worktree of it writes to the same threads. */
|
|
209
|
+
export function threadsDir(cwd) {
|
|
210
|
+
const common = git(cwd, ['rev-parse', '--git-common-dir']);
|
|
211
|
+
return common ? path.join(path.resolve(cwd, common), THREADS_DIR) : undefined;
|
|
212
|
+
}
|
|
213
|
+
function listThreads(dir) {
|
|
214
|
+
try {
|
|
215
|
+
return fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** A branch's thread file: a readable form of its name, and a hash of the exact name, so `a/b` and `a_b` never share one. */
|
|
222
|
+
function branchFile(branch) {
|
|
223
|
+
return `${branch.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80)}-${createHash('sha1').update(branch).digest('hex').slice(0, 8)}.jsonl`;
|
|
224
|
+
}
|
|
225
|
+
function byTime(a, b) {
|
|
226
|
+
return a.at < b.at ? -1 : a.at > b.at ? 1 : 0;
|
|
227
|
+
}
|
|
228
|
+
function num(v) {
|
|
229
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
230
|
+
}
|
|
231
|
+
function git(cwd, args) {
|
|
232
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 5000 });
|
|
233
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
234
|
+
}
|