@azure-id/orc 1.9.1 → 2.0.2
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/CHANGELOG.md +390 -0
- package/README-id.md +20 -36
- package/README.md +24 -34
- package/bin/build-agents.js +206 -109
- package/bin/cli.js +760 -62
- package/bin/gotcha-import.js +1081 -0
- package/bin/gotcha.js +1286 -0
- package/bin/graph-query.js +1 -1
- package/bin/graph.js +717 -717
- package/bin/habit.js +1453 -0
- package/bin/mockrun-catalog.js +281 -276
- package/bin/onboarding-content.js +4 -4
- package/bin/pricing.json +207 -200
- package/bin/run-undo.js +398 -0
- package/bin/trace-write.js +657 -0
- package/bin/verify-contracts.js +527 -81
- package/bin/verify-package.js +58 -11
- package/bin/webui/api.js +26 -0
- package/bin/webui/app.html +239 -232
- package/bin/webui/css/00-tokens.css +110 -92
- package/bin/webui/css/04-motion.css +249 -152
- package/bin/webui/css/06-responsive.css +203 -178
- package/bin/webui/css/panels/behaviour.css +205 -0
- package/bin/webui/css/panels/knowledge.css +563 -116
- package/bin/webui/fixtures/behaviour.js +532 -0
- package/bin/webui/fixtures/extra.js +2036 -2036
- package/bin/webui/fixtures/hookui.js +5 -5
- package/bin/webui/fixtures/index.js +34 -0
- package/bin/webui/fixtures/knowledge.js +7 -1
- package/bin/webui/fixtures/settings.js +305 -305
- package/bin/webui/fixtures/stats.js +16 -0
- package/bin/webui/i18n/en/behaviour.json +143 -0
- package/bin/webui/i18n/en/knowledge.json +117 -2
- package/bin/webui/i18n/en/nav.json +25 -24
- package/bin/webui/i18n/en/tour.json +37 -35
- package/bin/webui/i18n/id/behaviour.json +143 -0
- package/bin/webui/i18n/id/knowledge.json +117 -2
- package/bin/webui/i18n/id/nav.json +25 -24
- package/bin/webui/i18n/id/tour.json +37 -35
- package/bin/webui/js/01-i18n.js +155 -154
- package/bin/webui/js/90-tour.js +498 -494
- package/bin/webui/js/91-shortcuts.js +126 -126
- package/bin/webui/js/99-boot.js +121 -118
- package/bin/webui/js/panels/behaviour.js +1022 -0
- package/bin/webui/js/panels/knowledge.js +552 -7
- package/bin/webui/js/panels/overview.js +13 -4
- package/mock-run/INDEX.md +109 -107
- package/mock-run/context-combiner.md +100 -100
- package/mock-run/gotcha-import.md +118 -0
- package/mock-run/habits.md +129 -0
- package/mock-run/orc-budget.md +534 -534
- package/mock-run/orc-challenge-council.md +262 -262
- package/mock-run/orc-quick.md +6 -1
- package/mock-run/orc-ultra.md +103 -103
- package/package.json +1 -1
- package/templates/agents/MODEL-MAPPING.md +49 -49
- package/templates/agents/orc-advisor-opus-5-xhigh.md +50 -56
- package/templates/agents/orc-analyze-mini-opus-5-med.md +55 -60
- package/templates/agents/orc-analyze-mini-sonnet-5-high.md +55 -58
- package/templates/agents/orc-challenge-advisor-opus-5-med.md +75 -75
- package/templates/agents/orc-challenge-contrarian-opus-5-high.md +110 -110
- package/templates/agents/orc-challenge-executor-opus-5-med.md +114 -114
- package/templates/agents/orc-challenge-expansionist-opus-5-med.md +112 -112
- package/templates/agents/orc-challenge-judge-opus-5-high.md +132 -132
- package/templates/agents/orc-challenge-outsider-opus-5-low.md +109 -109
- package/templates/agents/orc-challenge-principles-opus-5-high.md +109 -109
- package/templates/agents/orc-challenge-reader-opus-5-low.md +90 -90
- package/templates/agents/orc-claude-writer-opus-4-8-high.md +48 -53
- package/templates/agents/orc-claude-writer-opus-5-med.md +48 -55
- package/templates/agents/orc-context-combiner-opus-5-high.md +78 -88
- package/templates/agents/orc-doc-checker-opus-5-low.md +108 -108
- package/templates/agents/orc-doc-writer-opus-5-med.md +134 -134
- package/templates/agents/orc-executor-haiku-4-5.md +14 -6
- package/templates/agents/orc-executor-opus-4-7-high.md +14 -6
- package/templates/agents/orc-executor-opus-4-7-med.md +14 -6
- package/templates/agents/orc-executor-opus-4-8-high.md +14 -6
- package/templates/agents/orc-executor-opus-5-high.md +15 -7
- package/templates/agents/orc-executor-opus-5-low.md +15 -7
- package/templates/agents/orc-executor-opus-5-med.md +15 -7
- package/templates/agents/orc-executor-sonnet-4-6-high.md +14 -6
- package/templates/agents/orc-executor-sonnet-4-6-med.md +14 -6
- package/templates/agents/orc-executor-sonnet-5-high.md +14 -6
- package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +1 -10
- package/templates/agents/orc-judge-opus-5-xhigh.md +81 -85
- package/templates/agents/orc-learn-writer-opus-5-low.md +67 -73
- package/templates/agents/orc-pattern-codifier-opus-5-med.md +58 -65
- package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +58 -63
- package/templates/agents/orc-planner-mini-opus-5-med.md +4 -7
- package/templates/agents/orc-planner-mini-sonnet-5-high.md +2 -3
- package/templates/agents/orc-planner-opus-5-med.md +157 -160
- package/templates/agents/orc-recon-opus-5-low.md +3 -10
- package/templates/agents/orc-recon-sonnet-4-6-med.md +1 -8
- package/templates/agents/orc-retro-opus-5-med.md +8 -10
- package/templates/agents/orc-retro-sonnet-5-high.md +6 -7
- package/templates/agents/orc-reviewer-opus-5-med.md +96 -60
- package/templates/agents/orc-scout-opus-5-low.md +35 -40
- package/templates/agents/orc-scout-sonnet-4-6-high.md +35 -39
- package/templates/agents/orc-system-analyst-opus-5-high.md +115 -120
- package/templates/agents/orc-test-author-opus-5-med.md +70 -71
- package/templates/agents/orc-test-designer-opus-5-high.md +158 -158
- package/templates/agents/orc-test-interpreter-opus-5-low.md +130 -130
- package/templates/agents/orc-trace-writer-haiku-4-5.md +3 -7
- package/templates/agents/orc-verifier-opus-5-med.md +77 -69
- package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +74 -79
- package/templates/agents/orc-wiki-scanner-opus-5-med.md +74 -81
- package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +97 -106
- package/templates/commands/orc-analyze.md +13 -21
- package/templates/commands/orc-fast.md +10 -15
- package/templates/commands/orc-poly.md +12 -21
- package/templates/commands/orc-pr-driver.md +11 -30
- package/templates/commands/orc-pr-setup.md +10 -31
- package/templates/commands/orc-route.md +11 -41
- package/templates/commands/orc-test.md +5 -60
- package/templates/commands/orc-ultra.md +17 -17
- package/templates/commands/orc-verify.md +11 -11
- package/templates/hooks/README.md +36 -2
- package/templates/hooks/orc-effort-guard.js +178 -178
- package/templates/hooks/orc-session-hook.js +264 -0
- package/templates/hooks/orc-statusline.js +9 -7
- package/templates/skills/_shared/README.md +9 -0
- package/templates/skills/_shared/code-graph.md +48 -56
- package/templates/skills/_shared/config-precedence.md +200 -198
- package/templates/skills/_shared/extra-dispatch.md +1331 -1346
- package/templates/skills/_shared/gotchas.md +228 -177
- package/templates/skills/_shared/habits.md +101 -0
- package/templates/skills/_shared/lane-contract.md +84 -0
- package/templates/skills/_shared/opus5-only.md +8 -8
- package/templates/skills/_shared/phases/README.md +142 -83
- package/templates/skills/_shared/phases/analyst-gates.md +125 -136
- package/templates/skills/_shared/phases/execution.md +8 -14
- package/templates/skills/_shared/phases/house-rules.md +27 -32
- package/templates/skills/_shared/phases/intake.md +127 -133
- package/templates/skills/_shared/phases/mock-example.md +46 -56
- package/templates/skills/_shared/phases/plan-handoff.md +91 -97
- package/templates/skills/_shared/phases/planning.md +7 -17
- package/templates/skills/_shared/phases/preflight.md +19 -42
- package/templates/skills/_shared/phases/review.md +23 -27
- package/templates/skills/_shared/phases/rules.md +18 -42
- package/templates/skills/_shared/phases/scoring.md +55 -65
- package/templates/skills/_shared/phases/security-checklist.md +46 -50
- package/templates/skills/_shared/phases/security.md +45 -55
- package/templates/skills/_shared/phases/ship.md +6 -15
- package/templates/skills/_shared/phases/stop-resume.md +2 -5
- package/templates/skills/_shared/phases/summary.md +73 -48
- package/templates/skills/_shared/phases/testgen.md +41 -51
- package/templates/skills/_shared/phases/trace-verbs.md +433 -0
- package/templates/skills/_shared/phases/trace.md +136 -367
- package/templates/skills/_shared/phases/verify.md +62 -70
- package/templates/skills/_shared/phases/wave-grouping.md +128 -133
- package/templates/skills/_shared/phases/wiki-consult.md +10 -6
- package/templates/skills/_shared/read-ladder.md +2 -55
- package/templates/skills/_shared/return-validation.md +17 -70
- package/templates/skills/_shared/review-slice.md +79 -0
- package/templates/skills/_shared/smoke-gate.md +46 -28
- package/templates/skills/context-combiner/SKILL.md +16 -45
- package/templates/skills/context-combiner/schemas/combined-report.md +78 -78
- package/templates/skills/orc/README.md +2 -2
- package/templates/skills/orc/SKILL.md +41 -69
- package/templates/skills/orc/config.md +9 -9
- package/templates/skills/orc/examples/full-run-mock.md +73 -73
- package/templates/skills/orc/references/effort-and-mode.md +222 -222
- package/templates/skills/orc/references/pattern-gate.md +89 -89
- package/templates/skills/orc/references/phases/intake.md +41 -47
- package/templates/skills/orc/references/phases/integration.md +13 -19
- package/templates/skills/orc/references/preflight-report.md +10 -11
- package/templates/skills/orc/references/ultra-mode.md +11 -9
- package/templates/skills/orc/subskills/orc-execution/SKILL.md +27 -73
- package/templates/skills/orc/subskills/orc-execution/core.md +12 -99
- package/templates/skills/orc/subskills/orc-execution/subagent.md +14 -13
- package/templates/skills/orc/subskills/orc-planner/SKILL.md +2 -2
- package/templates/skills/orc/subskills/orc-planner-mini/SKILL.md +121 -121
- package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +35 -76
- package/templates/skills/orc/subskills/orc-review-verify/core.md +52 -135
- package/templates/skills/orc/subskills/orc-review-verify/subagent.md +7 -7
- package/templates/skills/orc/subskills/orc-testgen/SKILL.md +34 -45
- package/templates/skills/orc/subskills/orc-testgen/core.md +20 -59
- package/templates/skills/orc/subskills/orc-testgen/subagent.md +7 -7
- package/templates/skills/orc-advisor/SKILL.md +56 -60
- package/templates/skills/orc-analyze/SKILL.md +32 -68
- package/templates/skills/orc-analyze/examples/analyze-mock.md +42 -42
- package/templates/skills/orc-analyze/schemas/report-audit.md +84 -83
- package/templates/skills/orc-analyze/schemas/report-prose.md +64 -63
- package/templates/skills/orc-analyze/schemas/report-requirement.md +78 -78
- package/templates/skills/orc-analyze-mini/SKILL.md +35 -70
- package/templates/skills/orc-challenge/README.md +142 -142
- package/templates/skills/orc-challenge/examples/council-full-roster.md +273 -273
- package/templates/skills/orc-challenge/references/council.md +315 -315
- package/templates/skills/orc-challenge/references/intake.md +171 -171
- package/templates/skills/orc-claude/SKILL.md +14 -14
- package/templates/skills/orc-diy/README.md +174 -143
- package/templates/skills/orc-diy/SKILL.md +23 -74
- package/templates/skills/orc-diy/references/blocks/pattern.md +18 -18
- package/templates/skills/orc-diy/references/flow-schema.md +1 -1
- package/templates/skills/orc-doc/README.md +229 -229
- package/templates/skills/orc-doc/examples/orc-doc-prd-run.md +325 -325
- package/templates/skills/orc-doc/references/chunking.md +527 -527
- package/templates/skills/orc-fast/SKILL.md +45 -73
- package/templates/skills/orc-judge/SKILL.md +77 -82
- package/templates/skills/orc-learn/SKILL.md +14 -14
- package/templates/skills/orc-learn/examples/learn-run-mock.md +61 -61
- package/templates/skills/orc-mini/SKILL.md +65 -113
- package/templates/skills/orc-pattern/SKILL.md +27 -48
- package/templates/skills/orc-poly/SKILL.md +32 -61
- package/templates/skills/orc-pr-driver/SKILL.md +25 -51
- package/templates/skills/orc-pr-driver/references/green-gate.md +113 -105
- package/templates/skills/orc-pr-setup/SKILL.md +20 -45
- package/templates/skills/orc-quick/README.md +43 -2
- package/templates/skills/orc-quick/SKILL.md +76 -107
- package/templates/skills/orc-quick/references/dispatch-gate.md +16 -5
- package/templates/skills/orc-quick/references/gh-mode.md +48 -1
- package/templates/skills/orc-quick/references/look.md +3 -1
- package/templates/skills/orc-retro/SKILL.md +19 -18
- package/templates/skills/orc-retro/examples/retro-mock.md +1 -1
- package/templates/skills/orc-route/SKILL.md +25 -45
- package/templates/skills/orc-test/SKILL.md +16 -37
- package/templates/skills/orc-verify/SKILL.md +21 -39
- package/templates/skills/orc-verify/examples/verify-mock.md +33 -33
- package/templates/skills/orc-wait/SKILL.md +156 -163
- package/templates/skills/orc-wiki/references/extra.md +1 -1
- package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -6
- package/templates/skills/orc-wiki/references/phases/phase-1.md +1 -6
- package/templates/skills/orc-wiki/references/phases/phase-2.md +1 -6
- package/templates/skills/orc-wiki/references/phases/phase-3.md +1 -6
- package/templates/skills/orc-wiki/references/phases/phase-3c.md +1 -6
package/bin/gotcha.js
ADDED
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ── `orc gotcha` v2 — the gotchas engine (v2.0.0 W4) ────────────────────────
|
|
3
|
+
//
|
|
4
|
+
// v1 had the MODEL assign ids, dedupe and match. v2 moves all three to this
|
|
5
|
+
// module, and adds a raw layer the promotion is COMPUTED from:
|
|
6
|
+
//
|
|
7
|
+
// .claude/orc/gotchas.md the ledger — the v1 format, plus 9 optional
|
|
8
|
+
// fields in a fixed order (`source:` and the rest)
|
|
9
|
+
// .claude/orc/gotchas-archive.md evicted entries (bin/cli.js `prune`, unchanged)
|
|
10
|
+
// .claude/orc/observations.jsonl one line per observed event, append-only,
|
|
11
|
+
// written by THIS module only
|
|
12
|
+
//
|
|
13
|
+
// None is in the install manifest (`isPrunable` matches only skills/, commands/,
|
|
14
|
+
// agents/, hooks/), so `orc update`, `--prune` and `doctor --fix` never touch them.
|
|
15
|
+
//
|
|
16
|
+
// The heading is UNCHANGED (`GOTCHA_HEAD` in bin/cli.js, kind ∈ repair|drift|
|
|
17
|
+
// review|verify): a 1.x CLI must still parse a 2.0 file. The new information
|
|
18
|
+
// rides in the optional `source:` field (DE-9). Review learning is ALWAYS ON
|
|
19
|
+
// (DE-10): `card` has no off answer, and `gotcha_card_budget` is a SIZE.
|
|
20
|
+
//
|
|
21
|
+
// Routed by bin/cli.js (`orc gotcha add|match|card|filter|observe|list
|
|
22
|
+
// --candidates|accept|quality|why|export`). The v1 reads (`status`, `list`,
|
|
23
|
+
// `show`, `prune`) stay in bin/cli.js, unchanged.
|
|
24
|
+
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
const crypto = require("crypto");
|
|
28
|
+
const { execFileSync } = require("child_process");
|
|
29
|
+
|
|
30
|
+
// ── The vocabularies (DATA) ─────────────────────────────────────────────────
|
|
31
|
+
const KINDS = ["repair", "drift", "review", "verify"];
|
|
32
|
+
// `source` → the `kind` it is filed under (`05` §2.2). A v1 entry has no
|
|
33
|
+
// `source:` line; its source is its kind.
|
|
34
|
+
const SOURCE_KIND = {
|
|
35
|
+
repair: "repair",
|
|
36
|
+
drift: "drift",
|
|
37
|
+
review: "review",
|
|
38
|
+
verify: "verify",
|
|
39
|
+
defect: "repair",
|
|
40
|
+
ci: "repair",
|
|
41
|
+
pr: "review",
|
|
42
|
+
sonar: "review",
|
|
43
|
+
sarif: "review",
|
|
44
|
+
dismissal: "review",
|
|
45
|
+
};
|
|
46
|
+
const V1_SOURCES = ["repair", "drift", "review", "verify"];
|
|
47
|
+
// An in-lane red → green with a reproduction promotes at once (`05` §4 (a)).
|
|
48
|
+
const IN_LANE_SOURCES = ["repair", "drift", "review", "verify", "defect", "ci"];
|
|
49
|
+
const CATEGORIES = [
|
|
50
|
+
"functional.logic",
|
|
51
|
+
"functional.check",
|
|
52
|
+
"functional.interface",
|
|
53
|
+
"functional.resource",
|
|
54
|
+
"functional.timing",
|
|
55
|
+
"functional.build",
|
|
56
|
+
"security",
|
|
57
|
+
"evolvability.structure",
|
|
58
|
+
"evolvability.documentation",
|
|
59
|
+
"evolvability.visual",
|
|
60
|
+
"test",
|
|
61
|
+
];
|
|
62
|
+
const SEVERITIES = ["P0", "P1", "P2", "P3"];
|
|
63
|
+
const POLARITIES = ["flag", "suppress"];
|
|
64
|
+
const OUTCOMES = ["addressed", "open", "disputed", "wontfix", "flaky"];
|
|
65
|
+
const AUTHORS = ["human", "bot", "orc"];
|
|
66
|
+
// The 8 required fields, in their FIXED order (v1, unchanged) …
|
|
67
|
+
const REQUIRED = ["trigger", "symptom", "cause", "fix", "scope", "origin", "hits", "last_seen"];
|
|
68
|
+
// … then the optional ones, fixed order when present. `cwe` rides beside
|
|
69
|
+
// `category` for a security entry (`05` §2.3 "security (+ cwe: CWE-###)").
|
|
70
|
+
const OPTIONAL = ["source", "rule", "category", "cwe", "severity", "polarity", "evidence", "helpful", "harmful"];
|
|
71
|
+
|
|
72
|
+
// ── The constants a retro can move ──────────────────────────────────────────
|
|
73
|
+
const JACCARD_MIN = 0.6; // word 3-gram Jaccard for a `sig` match
|
|
74
|
+
const WINDOW_DAYS = 90; // promotion (b), quiet, the quality window
|
|
75
|
+
const PROMOTE_CASES = 3; // (b) distinct addressed cases …
|
|
76
|
+
const PROMOTE_PRS = 2; // … in this many PRs
|
|
77
|
+
const DO_LINE_CASES = 5; // active → "do" line in executor slices
|
|
78
|
+
const SUPPRESS_DISPUTES = 2; // → proposed suppression
|
|
79
|
+
const MATCH_CAP = 3; // executor slice (v1)
|
|
80
|
+
const CARD_FLAG_CAP = 6; // flag + watch lines
|
|
81
|
+
const CARD_WATCH_CAP = 2;
|
|
82
|
+
const CARD_SUPPRESS_CAP = 2;
|
|
83
|
+
const CARD_BUDGET_DEFAULT = 600;
|
|
84
|
+
const CARD_BUDGET_MIN = 200;
|
|
85
|
+
const P3_CAP = 5;
|
|
86
|
+
const NOISE_MIN_FINDINGS = 10;
|
|
87
|
+
const NOISE_ACCEPTANCE = 0.35;
|
|
88
|
+
const QUALITY_FLOOR = 5; // reviews before `quality` answers
|
|
89
|
+
const QUALITY_TARGET = 0.6; // the acceptance a review kind aims at (the panel's tick)
|
|
90
|
+
const QUALITY_SERIES = 12; // reviews in the acceptance / P3 lines
|
|
91
|
+
const EVIDENCE_CAP = 5;
|
|
92
|
+
const SIG_MAX = 120;
|
|
93
|
+
const DAY = 86400000;
|
|
94
|
+
|
|
95
|
+
// ── Small helpers ───────────────────────────────────────────────────────────
|
|
96
|
+
const sha1 = (s) => crypto.createHash("sha1").update(String(s)).digest("hex");
|
|
97
|
+
const p2 = (n) => String(n).padStart(2, "0");
|
|
98
|
+
function dmy(d) {
|
|
99
|
+
return `${p2(d.getDate())}-${p2(d.getMonth() + 1)}-${d.getFullYear()}`;
|
|
100
|
+
}
|
|
101
|
+
// DD-MM-YYYY or YYYY-MM-DD[...] → ms, or NaN.
|
|
102
|
+
function dateMs(s) {
|
|
103
|
+
const t = String(s || "");
|
|
104
|
+
let m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(t);
|
|
105
|
+
if (m) return Date.UTC(+m[3], +m[2] - 1, +m[1]);
|
|
106
|
+
m = /^(\d{4})-(\d{2})-(\d{2})/.exec(t);
|
|
107
|
+
if (m) return Date.UTC(+m[1], +m[2] - 1, +m[3]);
|
|
108
|
+
return NaN;
|
|
109
|
+
}
|
|
110
|
+
// A UTC-midnight ms (what dateMs returns) → DD-MM-YYYY, with no zone shift.
|
|
111
|
+
function dmyUtc(ms) {
|
|
112
|
+
const d = new Date(ms);
|
|
113
|
+
return `${p2(d.getUTCDate())}-${p2(d.getUTCMonth() + 1)}-${d.getUTCFullYear()}`;
|
|
114
|
+
}
|
|
115
|
+
function isoDay(d) {
|
|
116
|
+
return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
|
|
117
|
+
}
|
|
118
|
+
const posix = (p) => String(p || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
119
|
+
const oneLine = (v) => String(v == null ? "" : v).replace(/[\r\n]+/g, " ").trim();
|
|
120
|
+
const estTokens = (s) => Math.ceil(String(s).length / 4);
|
|
121
|
+
|
|
122
|
+
// A glob → a RegExp. `**` crosses directories, `*` and `?` do not, `{a,b}` is
|
|
123
|
+
// an alternation. Paths are repo-relative with forward slashes.
|
|
124
|
+
function globRe(glob) {
|
|
125
|
+
const g = posix(glob);
|
|
126
|
+
let re = "";
|
|
127
|
+
for (let i = 0; i < g.length; i++) {
|
|
128
|
+
const c = g[i];
|
|
129
|
+
if (c === "*") {
|
|
130
|
+
if (g[i + 1] === "*") {
|
|
131
|
+
i++;
|
|
132
|
+
if (g[i + 1] === "/") {
|
|
133
|
+
i++;
|
|
134
|
+
re += "(?:.*/)?";
|
|
135
|
+
} else re += ".*";
|
|
136
|
+
} else re += "[^/]*";
|
|
137
|
+
} else if (c === "?") re += "[^/]";
|
|
138
|
+
else if (c === "{") {
|
|
139
|
+
const end = g.indexOf("}", i);
|
|
140
|
+
if (end === -1) re += "\\{";
|
|
141
|
+
else {
|
|
142
|
+
re += "(?:" + g.slice(i + 1, end).split(",").map((x) => x.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join("|") + ")";
|
|
143
|
+
i = end;
|
|
144
|
+
}
|
|
145
|
+
} else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
146
|
+
}
|
|
147
|
+
return new RegExp("^" + re + "$");
|
|
148
|
+
}
|
|
149
|
+
function inScope(scope, file) {
|
|
150
|
+
if (!scope) return false;
|
|
151
|
+
try {
|
|
152
|
+
return globRe(scope).test(posix(file));
|
|
153
|
+
} catch (_) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// A glob that covers the whole repo is the unfiltered injection the v1
|
|
158
|
+
// contract forbids.
|
|
159
|
+
function wholeRepo(scope) {
|
|
160
|
+
const s = posix(scope).trim();
|
|
161
|
+
return !s || /^(\*\*\/?)*\*?(\.\*)?$/.test(s) || s === "**/*";
|
|
162
|
+
}
|
|
163
|
+
// Glob specificity: the literal path segments before the first wildcard.
|
|
164
|
+
function specificity(scope) {
|
|
165
|
+
const segs = posix(scope).split("/");
|
|
166
|
+
let n = 0;
|
|
167
|
+
for (const s of segs) {
|
|
168
|
+
if (/[*?{]/.test(s)) break;
|
|
169
|
+
n++;
|
|
170
|
+
}
|
|
171
|
+
return 1 + n;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── `sig` — the normalized text of a finding with no rule id ────────────────
|
|
175
|
+
// Lower case, identifiers / numbers / code removed, max 120 chars (`05` §3).
|
|
176
|
+
// Secrets are redacted FIRST (§8) — a token never lands in the file.
|
|
177
|
+
const SECRET_RES = [
|
|
178
|
+
/\b(?:bearer|basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
179
|
+
/\b(?:api[_-]?key|secret|password|passwd|token|auth)\s*[:=]\s*\S+/gi,
|
|
180
|
+
/\b(?:sk|pk|ghp|gho|ghs|glpat|xox[abp])[-_][A-Za-z0-9_-]{8,}/g,
|
|
181
|
+
/\bAKIA[0-9A-Z]{12,}\b/g,
|
|
182
|
+
/\b[A-Za-z0-9+/_-]{32,}={0,2}/g,
|
|
183
|
+
];
|
|
184
|
+
function redact(text) {
|
|
185
|
+
let t = String(text || "");
|
|
186
|
+
for (const re of SECRET_RES) t = t.replace(re, " ");
|
|
187
|
+
return t;
|
|
188
|
+
}
|
|
189
|
+
function normalizeSig(text) {
|
|
190
|
+
let t = redact(text);
|
|
191
|
+
t = t
|
|
192
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
193
|
+
.replace(/`[^`]*`/g, " ")
|
|
194
|
+
.replace(/https?:\/\/\S+/g, " ")
|
|
195
|
+
.replace(/@[A-Za-z0-9_-]+/g, " ");
|
|
196
|
+
const words = t
|
|
197
|
+
.split(/\s+/)
|
|
198
|
+
.filter((w) => w && !/[_./\\()[\]{}<>=:;#$0-9]/.test(w.replace(/[,.!?;:]+$/, "")) && !/[a-z][A-Z]/.test(w))
|
|
199
|
+
.map((w) => w.toLowerCase().replace(/[^a-z' -]/g, ""))
|
|
200
|
+
.filter((w) => w && w !== "-");
|
|
201
|
+
let s = words.join(" ").replace(/\s+/g, " ").trim();
|
|
202
|
+
if (s.length > SIG_MAX) s = s.slice(0, SIG_MAX).replace(/\s+\S*$/, "").trim();
|
|
203
|
+
return s;
|
|
204
|
+
}
|
|
205
|
+
function grams(text) {
|
|
206
|
+
const w = String(text || "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
207
|
+
if (w.length < 3) return new Set(w.length ? [w.join(" ")] : []);
|
|
208
|
+
const out = new Set();
|
|
209
|
+
for (let i = 0; i + 3 <= w.length; i++) out.add(w.slice(i, i + 3).join(" "));
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
function jaccard(a, b) {
|
|
213
|
+
const A = grams(a);
|
|
214
|
+
const B = grams(b);
|
|
215
|
+
if (!A.size || !B.size) return 0;
|
|
216
|
+
let inter = 0;
|
|
217
|
+
for (const g of A) if (B.has(g)) inter++;
|
|
218
|
+
return inter / (A.size + B.size - inter);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Scope derivation (`05` §4) ──────────────────────────────────────────────
|
|
222
|
+
// The narrowest glob that covers every case: the file → `dir/**` → `**/*.<ext>`.
|
|
223
|
+
// A glob that would cover the whole repo is REFUSED, and the reason is named.
|
|
224
|
+
function deriveScope(paths) {
|
|
225
|
+
const ps = [...new Set(paths.map(posix).filter(Boolean))];
|
|
226
|
+
if (!ps.length) return { scope: null, refused: "no case carries a path" };
|
|
227
|
+
if (ps.length === 1) return { scope: ps[0], refused: null };
|
|
228
|
+
const dirs = ps.map((p) => p.split("/").slice(0, -1));
|
|
229
|
+
const common = [];
|
|
230
|
+
for (let i = 0; ; i++) {
|
|
231
|
+
const seg = dirs[0][i];
|
|
232
|
+
if (seg === undefined || !dirs.every((d) => d[i] === seg)) break;
|
|
233
|
+
common.push(seg);
|
|
234
|
+
}
|
|
235
|
+
if (common.length) return { scope: common.join("/") + "/**", refused: null };
|
|
236
|
+
const exts = [...new Set(ps.map((p) => (/\.([A-Za-z0-9]+)$/.exec(p) || [])[1] || ""))];
|
|
237
|
+
if (exts.length === 1 && exts[0]) return { scope: `**/*.${exts[0]}`, refused: null };
|
|
238
|
+
return { scope: null, refused: "the only glob that covers every case covers the whole repo" };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── The ledger (read through bin/cli.js `parseGotchas`, written here) ───────
|
|
242
|
+
function entryView(e) {
|
|
243
|
+
const f = e.fields;
|
|
244
|
+
const source = f.source && SOURCE_KIND[f.source] ? f.source : e.kind;
|
|
245
|
+
const num = (v) => {
|
|
246
|
+
const n = Number.parseInt(v, 10);
|
|
247
|
+
return Number.isFinite(n) ? n : 0;
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
id: e.id,
|
|
251
|
+
area: e.area,
|
|
252
|
+
kind: e.kind,
|
|
253
|
+
source,
|
|
254
|
+
rule: f.rule || null,
|
|
255
|
+
category: f.category || null,
|
|
256
|
+
cwe: f.cwe || null,
|
|
257
|
+
severity: SEVERITIES.includes(f.severity) ? f.severity : null,
|
|
258
|
+
polarity: f.polarity === "suppress" ? "suppress" : "flag",
|
|
259
|
+
scope: f.scope || null,
|
|
260
|
+
trigger: f.trigger || null,
|
|
261
|
+
symptom: f.symptom || null,
|
|
262
|
+
fix: f.fix || null,
|
|
263
|
+
origin: f.origin || null,
|
|
264
|
+
hits: e.hits,
|
|
265
|
+
last_seen: f.last_seen || null,
|
|
266
|
+
evidence: f.evidence ? f.evidence.split(/\s+·\s+/).map((x) => x.trim()).filter(Boolean) : [],
|
|
267
|
+
helpful: num(f.helpful),
|
|
268
|
+
harmful: num(f.harmful),
|
|
269
|
+
raw: e,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function pad(name) {
|
|
273
|
+
return (name + ":").padEnd(11);
|
|
274
|
+
}
|
|
275
|
+
// Render one entry from its fields: heading, the 8 required, the optional
|
|
276
|
+
// ones present, then any other line the user wrote (kept, never dropped).
|
|
277
|
+
function renderEntry(id, area, kind, fields, extra) {
|
|
278
|
+
const out = [`## ${id} · ${area} · ${kind}`];
|
|
279
|
+
for (const k of REQUIRED) out.push(`- ${pad(k)}${oneLine(fields[k])}`);
|
|
280
|
+
for (const k of OPTIONAL) if (fields[k] !== undefined && fields[k] !== null && String(fields[k]) !== "") out.push(`- ${pad(k)}${oneLine(fields[k])}`);
|
|
281
|
+
for (const l of extra || []) out.push(l);
|
|
282
|
+
return out.join("\n");
|
|
283
|
+
}
|
|
284
|
+
function extraLines(e) {
|
|
285
|
+
return e.lines.slice(1).filter((l) => {
|
|
286
|
+
if (!l.trim()) return false;
|
|
287
|
+
const f = /^-\s+([a-z_]+):/.exec(l);
|
|
288
|
+
return !(f && (REQUIRED.includes(f[1]) || OPTIONAL.includes(f[1])));
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function makeStore(claudeDir, deps) {
|
|
293
|
+
const file = deps.gotchasPath(claudeDir);
|
|
294
|
+
const archive = deps.gotchasArchivePath(claudeDir);
|
|
295
|
+
const obsFile = path.join(claudeDir, "orc", "observations.jsonl");
|
|
296
|
+
const readRaw = () => (fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null);
|
|
297
|
+
function load() {
|
|
298
|
+
const raw = readRaw();
|
|
299
|
+
const parsed = deps.parseGotchas(file);
|
|
300
|
+
return { raw, eol: raw && raw.includes("\r\n") ? "\r\n" : "\n", preamble: parsed.preamble, entries: parsed.entries };
|
|
301
|
+
}
|
|
302
|
+
function save(led) {
|
|
303
|
+
const body =
|
|
304
|
+
(led.preamble ? led.preamble + "\n\n" : led.entries.length ? "# Gotchas\n\n" : "") +
|
|
305
|
+
led.entries.map((e) => e.text).join("\n\n") +
|
|
306
|
+
"\n";
|
|
307
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
308
|
+
const tmp = file + ".tmp";
|
|
309
|
+
fs.writeFileSync(tmp, led.eol === "\r\n" ? body.replace(/\r?\n/g, "\r\n") : body);
|
|
310
|
+
fs.renameSync(tmp, file);
|
|
311
|
+
}
|
|
312
|
+
// IDs are monotonic and NEVER reused — the archive counts too.
|
|
313
|
+
function nextId(led) {
|
|
314
|
+
const ids = led.entries.map((e) => e.id).concat(deps.parseGotchas(archive).entries.map((e) => e.id));
|
|
315
|
+
const max = ids.reduce((m, id) => Math.max(m, Number(id.slice(2)) || 0), 0);
|
|
316
|
+
if (max >= 999) return null;
|
|
317
|
+
return "G-" + String(max + 1).padStart(3, "0");
|
|
318
|
+
}
|
|
319
|
+
function readObs() {
|
|
320
|
+
if (!fs.existsSync(obsFile)) return [];
|
|
321
|
+
const byId = new Map();
|
|
322
|
+
for (const line of fs.readFileSync(obsFile, "utf8").replace(/\r\n/g, "\n").split("\n")) {
|
|
323
|
+
if (!line.trim()) continue;
|
|
324
|
+
let o;
|
|
325
|
+
try {
|
|
326
|
+
o = JSON.parse(line);
|
|
327
|
+
} catch (_) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (o && o.obs) {
|
|
331
|
+
byId.delete(o.obs); // the LAST line per obs id wins, in file order
|
|
332
|
+
byId.set(o.obs, o);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return [...byId.values()];
|
|
336
|
+
}
|
|
337
|
+
function appendObs(o) {
|
|
338
|
+
fs.mkdirSync(path.dirname(obsFile), { recursive: true });
|
|
339
|
+
fs.appendFileSync(obsFile, JSON.stringify(o) + "\n");
|
|
340
|
+
}
|
|
341
|
+
return { file, archive, obsFile, load, save, nextId, readObs, appendObs };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Linking an observation to an entry (the dedupe key) ─────────────────────
|
|
345
|
+
// Key = (rule or sig-signature, category, scope). A rule matches a rule; a
|
|
346
|
+
// sig matches the entry's symptom at 3-gram Jaccard ≥ JACCARD_MIN. The path
|
|
347
|
+
// must sit inside the entry's scope; a category must agree when both carry one.
|
|
348
|
+
function linksTo(o, v) {
|
|
349
|
+
if (o.category && v.category && o.category !== v.category) return false;
|
|
350
|
+
if (o.path && v.scope && !inScope(v.scope, o.path)) return false;
|
|
351
|
+
if (o.rule || v.rule) return !!(o.rule && v.rule && o.rule === v.rule);
|
|
352
|
+
return !!(o.sig && v.symptom && jaccard(o.sig, normalizeSig(v.symptom) || v.symptom) >= JACCARD_MIN);
|
|
353
|
+
}
|
|
354
|
+
function prOf(o) {
|
|
355
|
+
if (o.pr !== undefined && o.pr !== null && o.pr !== "") return String(o.pr);
|
|
356
|
+
const m = /\b(?:PR|MR)\s*#?\s*(\d+)/i.exec(o.ref || "");
|
|
357
|
+
return m ? m[1] : null;
|
|
358
|
+
}
|
|
359
|
+
const isStrong = (o) =>
|
|
360
|
+
o.outcome === "addressed" && ((o.source === "pr" && o.author === "human") || o.source === "sonar" || o.source === "defect");
|
|
361
|
+
// Human findings are MINED. Bot findings (a review bot, ORC's own reviewer)
|
|
362
|
+
// are MEASURED, not mined — except an in-lane red → green, which is a repair.
|
|
363
|
+
const isMined = (o) => o.author === "human" || ["sonar", "sarif", "ci", "defect"].includes(o.source) || (o.author === "orc" && o.reproduced === true);
|
|
364
|
+
const isLintRule = (r) => /^(eslint|ruff):/.test(r || "");
|
|
365
|
+
|
|
366
|
+
// ── Promotion (`05` §4), a PURE function of a candidate's cases ─────────────
|
|
367
|
+
// Returns { promote: bool, why, rule, needs }. Order: (a) in-lane, the miss,
|
|
368
|
+
// (c) security, (b) repetition. Every case counted is an `addressed` one.
|
|
369
|
+
function promotion(cases, nowMs) {
|
|
370
|
+
const now = nowMs === undefined ? Date.now() : nowMs;
|
|
371
|
+
const addressed = cases.filter((o) => o.outcome === "addressed");
|
|
372
|
+
const inLane = addressed.find((o) => o.reproduced === true && IN_LANE_SOURCES.includes(o.source));
|
|
373
|
+
if (inLane) return { promote: true, rule: "a", why: "in-lane red → green with a reproduction" };
|
|
374
|
+
const miss = addressed.find((o) => o.miss === true);
|
|
375
|
+
if (miss) return { promote: true, rule: "miss", why: "a miss: an ORC review read these lines and did not flag them" };
|
|
376
|
+
const sec = addressed.find(
|
|
377
|
+
(o) => o.category === "security" && (/^CWE-\d+$/.test(o.cwe || "") || ["HIGH", "BLOCKER"].includes(String(o.impact || "").toUpperCase()))
|
|
378
|
+
);
|
|
379
|
+
if (sec) return { promote: true, rule: "c", why: "security with a HIGH/BLOCKER impact or a CWE tag — at 1 case" };
|
|
380
|
+
const recent = addressed.filter((o) => {
|
|
381
|
+
const t = dateMs(o.at);
|
|
382
|
+
return Number.isFinite(t) && now - t <= WINDOW_DAYS * DAY;
|
|
383
|
+
});
|
|
384
|
+
const distinct = new Set(recent.map((o) => o.obs)).size;
|
|
385
|
+
const prs = new Set(recent.map(prOf).filter(Boolean)).size;
|
|
386
|
+
if (distinct >= PROMOTE_CASES && prs >= PROMOTE_PRS)
|
|
387
|
+
return { promote: true, rule: "b", why: `${distinct} addressed cases in ${prs} PRs within ${WINDOW_DAYS} days` };
|
|
388
|
+
const need = [];
|
|
389
|
+
if (distinct < PROMOTE_CASES) need.push(`${PROMOTE_CASES - distinct} more addressed case${PROMOTE_CASES - distinct === 1 ? "" : "s"} (${distinct} of ${PROMOTE_CASES})`);
|
|
390
|
+
if (prs < PROMOTE_PRS) need.push(`${PROMOTE_PRS - prs} more PR${PROMOTE_PRS - prs === 1 ? "" : "s"} (${prs} of ${PROMOTE_PRS})`);
|
|
391
|
+
return { promote: false, rule: null, why: null, needs: need.join(" in ") + ` within ${WINDOW_DAYS} days`, cases: distinct, prs };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── Candidates: the mined observations no entry has absorbed yet ────────────
|
|
395
|
+
function candidates(entries, obs, nowMs) {
|
|
396
|
+
const views = entries.map(entryView);
|
|
397
|
+
const groups = [];
|
|
398
|
+
for (const o of obs) {
|
|
399
|
+
if (!isMined(o)) continue;
|
|
400
|
+
if (!["addressed", "disputed", "open", "wontfix"].includes(o.outcome)) continue;
|
|
401
|
+
if (views.some((v) => linksTo(o, v))) continue;
|
|
402
|
+
let g = groups.find((x) =>
|
|
403
|
+
o.rule ? x.rule === o.rule && x.category === (o.category || null) : !x.rule && x.category === (o.category || null) && jaccard(o.sig, x.sig) >= JACCARD_MIN
|
|
404
|
+
);
|
|
405
|
+
if (!g) {
|
|
406
|
+
g = { rule: o.rule || null, sig: o.sig || null, category: o.category || null, cases: [] };
|
|
407
|
+
groups.push(g);
|
|
408
|
+
}
|
|
409
|
+
g.cases.push(o);
|
|
410
|
+
}
|
|
411
|
+
const out = [];
|
|
412
|
+
for (const g of groups) {
|
|
413
|
+
const addressed = g.cases.filter((o) => o.outcome === "addressed").length;
|
|
414
|
+
const disputed = g.cases.filter((o) => o.outcome === "disputed").length;
|
|
415
|
+
// One `addressed` or `disputed` outcome makes a candidate; an open case alone does not.
|
|
416
|
+
if (!addressed && !disputed) continue;
|
|
417
|
+
const { scope, refused } = deriveScope(g.cases.map((o) => o.path));
|
|
418
|
+
const key = `${g.rule || "sig:" + g.sig}|${g.category || ""}|${scope || ""}`;
|
|
419
|
+
const gating = ["P0", "P1"].some((s) => g.cases.some((o) => o.severity === s));
|
|
420
|
+
const suppressible = !(g.category === "security" || ((g.category || "").startsWith("functional.") && gating));
|
|
421
|
+
const isSuppression = disputed >= SUPPRESS_DISPUTES && disputed > addressed && suppressible;
|
|
422
|
+
const kind = isSuppression ? "suppression" : isLintRule(g.rule) ? "project-action" : "candidate";
|
|
423
|
+
const p = kind === "candidate" ? promotion(g.cases, nowMs) : { promote: false };
|
|
424
|
+
const first = g.cases[0];
|
|
425
|
+
const strong = g.cases.some(isStrong);
|
|
426
|
+
out.push({
|
|
427
|
+
id: "C-" + sha1((isSuppression ? "suppress|" : "") + key).slice(0, 6),
|
|
428
|
+
kind,
|
|
429
|
+
key,
|
|
430
|
+
rule: g.rule,
|
|
431
|
+
sig: g.sig,
|
|
432
|
+
category: g.category,
|
|
433
|
+
scope,
|
|
434
|
+
scope_refused: refused,
|
|
435
|
+
lang: first.lang || null,
|
|
436
|
+
source: (g.cases.find(isStrong) || first).source,
|
|
437
|
+
cases: g.cases.length,
|
|
438
|
+
addressed,
|
|
439
|
+
disputed,
|
|
440
|
+
prs: new Set(g.cases.map(prOf).filter(Boolean)).size,
|
|
441
|
+
strong,
|
|
442
|
+
watch: kind === "candidate" && strong && !!scope,
|
|
443
|
+
severity: g.cases.map((o) => o.severity).filter(Boolean).sort()[0] || null,
|
|
444
|
+
cwe: (g.cases.find((o) => o.cwe) || {}).cwe || null,
|
|
445
|
+
last_at: g.cases.map((o) => o.at).filter(Boolean).sort().pop() || null,
|
|
446
|
+
promote: kind === "candidate" && !!scope && p.promote,
|
|
447
|
+
promote_rule: p.rule || null,
|
|
448
|
+
why: p.why || null,
|
|
449
|
+
needs:
|
|
450
|
+
kind === "project-action"
|
|
451
|
+
? `a lint rule — turn the linter rule on (${g.rule}); it is never a review item`
|
|
452
|
+
: kind === "suppression"
|
|
453
|
+
? "a person accepts it: orc gotcha accept <id> (a suppression is never automatic)"
|
|
454
|
+
: refused
|
|
455
|
+
? `no scope: ${refused}`
|
|
456
|
+
: p.promote
|
|
457
|
+
? null
|
|
458
|
+
: p.needs,
|
|
459
|
+
obs: g.cases.map((o) => o.obs),
|
|
460
|
+
_cases: g.cases,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
const publicCand = (c) => {
|
|
466
|
+
const o = Object.assign({}, c);
|
|
467
|
+
delete o._cases;
|
|
468
|
+
return o;
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// A candidate → an entry body. Structured fields only (§8): the rule, the
|
|
472
|
+
// category, the scope and the normalized `sig` — never a sentence from a
|
|
473
|
+
// comment body.
|
|
474
|
+
function entryFromCandidate(c, why, nowMs) {
|
|
475
|
+
const now = new Date(nowMs === undefined ? Date.now() : nowMs);
|
|
476
|
+
const src = c.kind === "suppression" ? "dismissal" : SOURCE_KIND[c.source] ? c.source : "review";
|
|
477
|
+
const anchors = [];
|
|
478
|
+
for (const o of c._cases) {
|
|
479
|
+
if (!o.path) continue;
|
|
480
|
+
const a = posix(o.path) + (Array.isArray(o.lines) && o.lines.length ? ":" + o.lines[0] : "");
|
|
481
|
+
if (!anchors.includes(a)) anchors.push(a);
|
|
482
|
+
}
|
|
483
|
+
const what = c.rule ? `${c.rule}${c.sig ? " — " + c.sig : ""}` : c.sig || c.category || "a recurring finding";
|
|
484
|
+
const seen = c._cases.map((o) => dateMs(o.at)).filter(Number.isFinite);
|
|
485
|
+
return {
|
|
486
|
+
area: oneLine(c.lang || (/\.([A-Za-z0-9]+)$/.exec(c.scope || "") || [])[1] || "any").replace(/·/g, "-") || "any",
|
|
487
|
+
kind: SOURCE_KIND[src],
|
|
488
|
+
fields: {
|
|
489
|
+
trigger: `a change inside ${c.scope}${c.category ? " (" + c.category + ")" : ""}`,
|
|
490
|
+
symptom: what,
|
|
491
|
+
cause: c.kind === "suppression" ? "the team disputed this advice" : `${c.addressed} addressed case${c.addressed === 1 ? "" : "s"} recorded in observations.jsonl`,
|
|
492
|
+
fix: c.kind === "suppression" ? "do not flag it here" : "(see `orc gotcha why`)",
|
|
493
|
+
scope: c.scope,
|
|
494
|
+
origin: `${why} · ${dmy(now)} · ${c.cases} case${c.cases === 1 ? "" : "s"} · ${c.prs} PR${c.prs === 1 ? "" : "s"}`,
|
|
495
|
+
hits: String(c.kind === "suppression" ? c.disputed : c.addressed || 1),
|
|
496
|
+
last_seen: seen.length ? dmyUtc(Math.max(...seen)) : dmy(now),
|
|
497
|
+
source: src,
|
|
498
|
+
rule: c.rule || undefined,
|
|
499
|
+
category: c.category || undefined,
|
|
500
|
+
cwe: c.cwe || undefined,
|
|
501
|
+
severity: c.severity || undefined,
|
|
502
|
+
polarity: c.kind === "suppression" ? "suppress" : "flag",
|
|
503
|
+
evidence: anchors.slice(0, EVIDENCE_CAP).join(" · ") || undefined,
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── Validating an `add` body. Returns { body } or { field, err } ────────────
|
|
509
|
+
function validateAdd(b) {
|
|
510
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) return { field: "body", err: "must be one JSON object" };
|
|
511
|
+
const fields = b.fields && typeof b.fields === "object" ? Object.assign({}, b.fields) : {};
|
|
512
|
+
for (const k of REQUIRED.concat(OPTIONAL)) if (b[k] !== undefined && fields[k] === undefined) fields[k] = b[k];
|
|
513
|
+
const source = fields.source ? String(fields.source) : null;
|
|
514
|
+
if (source && !SOURCE_KIND[source]) return { field: "source", err: `must be one of: ${Object.keys(SOURCE_KIND).join(", ")}` };
|
|
515
|
+
const kind = b.kind ? String(b.kind) : source ? SOURCE_KIND[source] : null;
|
|
516
|
+
if (!KINDS.includes(kind)) return { field: "kind", err: `must be one of: ${KINDS.join(", ")}` };
|
|
517
|
+
if (source && SOURCE_KIND[source] !== kind) return { field: "kind", err: `source ${source} is filed under kind ${SOURCE_KIND[source]}` };
|
|
518
|
+
const area = oneLine(b.area || b.lang || "");
|
|
519
|
+
if (!area || area.includes("·")) return { field: "area", err: "must be a non-empty word with no `·`" };
|
|
520
|
+
const today = dmy(new Date());
|
|
521
|
+
if (fields.hits === undefined) fields.hits = 1;
|
|
522
|
+
if (fields.last_seen === undefined) fields.last_seen = today;
|
|
523
|
+
for (const k of REQUIRED) {
|
|
524
|
+
const v = fields[k];
|
|
525
|
+
if (v === undefined || v === null || !oneLine(v)) return { field: k, err: "is required" };
|
|
526
|
+
if (/[\r\n]/.test(String(v))) return { field: k, err: "must be one line" };
|
|
527
|
+
}
|
|
528
|
+
if (!/^\d+$/.test(String(fields.hits))) return { field: "hits", err: "must be a whole number" };
|
|
529
|
+
if (!/^\d{2}-\d{2}-\d{4}$/.test(String(fields.last_seen))) return { field: "last_seen", err: "must be DD-MM-YYYY" };
|
|
530
|
+
if (wholeRepo(fields.scope)) return { field: "scope", err: "covers the whole repo — a gotcha is never injected unfiltered" };
|
|
531
|
+
if (fields.category && !CATEGORIES.includes(fields.category)) return { field: "category", err: `must be one of: ${CATEGORIES.join(", ")}` };
|
|
532
|
+
if (fields.cwe && !/^CWE-\d+$/.test(fields.cwe)) return { field: "cwe", err: "must be CWE-<number>" };
|
|
533
|
+
if (fields.severity && !SEVERITIES.includes(fields.severity)) return { field: "severity", err: "must be P0, P1, P2 or P3" };
|
|
534
|
+
if (fields.polarity && !POLARITIES.includes(fields.polarity)) return { field: "polarity", err: "must be flag or suppress" };
|
|
535
|
+
for (const k of ["helpful", "harmful"]) if (fields[k] !== undefined && !/^\d+$/.test(String(fields[k]))) return { field: k, err: "must be a whole number" };
|
|
536
|
+
for (const k of OPTIONAL) if (fields[k] !== undefined && /[\r\n]/.test(String(fields[k]))) return { field: k, err: "must be one line" };
|
|
537
|
+
return { body: { area, kind, fields } };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Dedupe on the key: same rule (or symptom at Jaccard ≥ 0.6 on the normalized
|
|
541
|
+
// text), same category when both carry one, same scope.
|
|
542
|
+
function findDuplicate(entries, body) {
|
|
543
|
+
const f = body.fields;
|
|
544
|
+
return entries.find((e) => {
|
|
545
|
+
const v = entryView(e);
|
|
546
|
+
if (posix(v.scope) !== posix(f.scope)) return false;
|
|
547
|
+
if (f.category && v.category && f.category !== v.category) return false;
|
|
548
|
+
if (f.rule || v.rule) return f.rule === v.rule;
|
|
549
|
+
const a = normalizeSig(f.symptom) || String(f.symptom).toLowerCase();
|
|
550
|
+
const b = normalizeSig(v.symptom) || String(v.symptom || "").toLowerCase();
|
|
551
|
+
return a === b || jaccard(a, b) >= JACCARD_MIN;
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// A match bumps `hits`/`last_seen` and adds the evidence line — never a second entry.
|
|
556
|
+
function bumpEntry(e, { by = 1, seen, evidence, helpful = 0, harmful = 0 } = {}) {
|
|
557
|
+
const v = entryView(e);
|
|
558
|
+
const f = Object.assign({}, e.fields);
|
|
559
|
+
f.hits = String(e.hits + by);
|
|
560
|
+
const cur = dateMs(f.last_seen);
|
|
561
|
+
const nxt = dateMs(seen);
|
|
562
|
+
if (seen && (!Number.isFinite(cur) || nxt > cur)) f.last_seen = seen;
|
|
563
|
+
if (evidence && evidence.length) {
|
|
564
|
+
const list = v.evidence.slice();
|
|
565
|
+
for (const a of evidence) if (a && !list.includes(a)) list.push(a);
|
|
566
|
+
f.evidence = list.slice(-EVIDENCE_CAP).join(" · ");
|
|
567
|
+
}
|
|
568
|
+
if (helpful) f.helpful = String(v.helpful + helpful);
|
|
569
|
+
if (harmful) f.harmful = String(v.harmful + harmful);
|
|
570
|
+
e.fields = f;
|
|
571
|
+
e.hits = Number(f.hits);
|
|
572
|
+
e.text = renderEntry(e.id, e.area, e.kind, f, extraLines(e));
|
|
573
|
+
e.lines = e.text.split("\n");
|
|
574
|
+
return e;
|
|
575
|
+
}
|
|
576
|
+
function appendEntry(store, led, body) {
|
|
577
|
+
const id = store.nextId(led);
|
|
578
|
+
if (!id) return null;
|
|
579
|
+
const text = renderEntry(id, body.area, body.kind, body.fields, []);
|
|
580
|
+
const e = { id, area: body.area, kind: body.kind, lines: text.split("\n"), fields: {}, text };
|
|
581
|
+
for (const k of REQUIRED.concat(OPTIONAL)) if (body.fields[k] !== undefined && body.fields[k] !== null && String(body.fields[k]) !== "") e.fields[k] = oneLine(body.fields[k]);
|
|
582
|
+
e.hits = Number(e.fields.hits) || 0;
|
|
583
|
+
led.entries.push(e);
|
|
584
|
+
return e;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── Derived states: orphaned, quiet, "do" line ──────────────────────────────
|
|
588
|
+
function trackedFiles(repoRoot) {
|
|
589
|
+
try {
|
|
590
|
+
const out = execFileSync("git", ["ls-files", "-z"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024 });
|
|
591
|
+
return out.split("\0").filter(Boolean);
|
|
592
|
+
} catch (_) {
|
|
593
|
+
return null; // not a git repo: the orphan check cannot run, so it marks nothing
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function orphanReason(v, tracked, repoRoot) {
|
|
597
|
+
if (!tracked) return null;
|
|
598
|
+
if (v.scope && !tracked.some((f) => inScope(v.scope, f))) return "its scope matches no tracked file";
|
|
599
|
+
if (v.evidence.length) {
|
|
600
|
+
const alive = v.evidence.some((a) => fs.existsSync(path.join(repoRoot, a.replace(/:\d+(?:-\d+)?$/, ""))));
|
|
601
|
+
if (!alive) return "every evidence anchor points at a file that no longer exists";
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
function isQuiet(v, nowMs) {
|
|
606
|
+
const t = dateMs(v.last_seen);
|
|
607
|
+
return Number.isFinite(t) && nowMs - t > WINDOW_DAYS * DAY;
|
|
608
|
+
}
|
|
609
|
+
// Activation date = the first DD-MM-YYYY in `origin`.
|
|
610
|
+
function activation(v) {
|
|
611
|
+
const m = /(\d{2}-\d{2}-\d{4})/.exec(v.origin || "");
|
|
612
|
+
return m ? dateMs(m[1]) : NaN;
|
|
613
|
+
}
|
|
614
|
+
// Executor eligibility (`05` §5.1): a v1 source, OR the "do" line — fired
|
|
615
|
+
// again after activation AND 5+ total cases.
|
|
616
|
+
function doLine(v, obs) {
|
|
617
|
+
if (V1_SOURCES.includes(v.source)) return true;
|
|
618
|
+
if (v.hits < DO_LINE_CASES) return false;
|
|
619
|
+
const act = activation(v);
|
|
620
|
+
if (!Number.isFinite(act)) return false;
|
|
621
|
+
return obs.some((o) => o.outcome === "addressed" && linksTo(o, v) && dateMs(o.at) > act);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ── Card lines ──────────────────────────────────────────────────────────────
|
|
625
|
+
const SEV_W = { P0: 4, P1: 3, P2: 2, P3: 1 };
|
|
626
|
+
function rankOf(v, nowMs) {
|
|
627
|
+
const t = dateMs(v.last_seen);
|
|
628
|
+
const days = Number.isFinite(t) ? Math.max(0, (nowMs - t) / DAY) : WINDOW_DAYS * 2;
|
|
629
|
+
return (SEV_W[v.severity] || 2) * Math.log(1 + Math.max(1, v.hits)) * (1 / (1 + days / WINDOW_DAYS)) * specificity(v.scope || "");
|
|
630
|
+
}
|
|
631
|
+
function lineFor(v) {
|
|
632
|
+
const label = v.rule || v.source;
|
|
633
|
+
const fix = v.fix && !v.fix.startsWith("(") ? ` → ${v.fix}` : "";
|
|
634
|
+
const count = v.hits > 1 ? `(${v.hits}×)` : IN_LANE_SOURCES.includes(v.source) ? "(reproduced)" : `(${v.hits}×)`;
|
|
635
|
+
return `${v.id} ${label} · ${v.scope} · ${v.symptom}${fix} ${count}`;
|
|
636
|
+
}
|
|
637
|
+
function watchLine(c) {
|
|
638
|
+
return `watch: ${c.id} ${c.rule || c.source} · ${c.scope} · ${c.sig || c.category || "a finding"} (seen ${c.addressed}×)`;
|
|
639
|
+
}
|
|
640
|
+
function suppressLine(v) {
|
|
641
|
+
return `do not flag: ${v.id} · ${v.scope} · ${v.symptom} (the team disputed this ${v.hits}×)`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── Quality (acceptance of ORC's own findings) ──────────────────────────────
|
|
645
|
+
function quality(obs, entries, windowDays, nowMs) {
|
|
646
|
+
const orc = obs.filter((o) => o.author === "orc" && (() => {
|
|
647
|
+
const t = dateMs(o.at);
|
|
648
|
+
return !Number.isFinite(t) || nowMs - t <= windowDays * DAY;
|
|
649
|
+
})());
|
|
650
|
+
const runs = [...new Set(orc.map((o) => o.run).filter(Boolean))];
|
|
651
|
+
const cats = {};
|
|
652
|
+
for (const o of orc) {
|
|
653
|
+
const c = o.category || "uncategorized";
|
|
654
|
+
const r = (cats[c] = cats[c] || { category: c, findings: 0, addressed: 0, disputed: 0, wontfix: 0, open: 0 });
|
|
655
|
+
r.findings++;
|
|
656
|
+
if (r[o.outcome] !== undefined) r[o.outcome]++;
|
|
657
|
+
}
|
|
658
|
+
const categories = Object.values(cats)
|
|
659
|
+
.map((r) => {
|
|
660
|
+
const decided = r.addressed + r.disputed + r.wontfix;
|
|
661
|
+
const acceptance = decided ? Math.round((r.addressed / decided) * 1000) / 1000 : null;
|
|
662
|
+
return Object.assign(r, { acceptance, noisy: r.findings >= NOISE_MIN_FINDINGS && acceptance !== null && acceptance < NOISE_ACCEPTANCE });
|
|
663
|
+
})
|
|
664
|
+
.sort((a, b) => b.findings - a.findings || a.category.localeCompare(b.category));
|
|
665
|
+
const p3 = orc.filter((o) => o.severity === "P3").length;
|
|
666
|
+
const sorted = orc.filter((o) => Number.isFinite(dateMs(o.at))).sort((a, b) => dateMs(a.at) - dateMs(b.at));
|
|
667
|
+
const half = Math.floor(sorted.length / 2);
|
|
668
|
+
const acc = (xs) => {
|
|
669
|
+
const d = xs.filter((o) => ["addressed", "disputed", "wontfix"].includes(o.outcome));
|
|
670
|
+
return d.length ? Math.round((d.filter((o) => o.outcome === "addressed").length / d.length) * 1000) / 1000 : null;
|
|
671
|
+
};
|
|
672
|
+
const first = acc(sorted.slice(0, half));
|
|
673
|
+
const second = acc(sorted.slice(half));
|
|
674
|
+
const gotchas = entries.map(entryView).map((v) => ({
|
|
675
|
+
id: v.id,
|
|
676
|
+
helpful: v.helpful,
|
|
677
|
+
harmful: v.harmful,
|
|
678
|
+
retire: v.harmful >= 3 && v.harmful >= v.helpful,
|
|
679
|
+
}));
|
|
680
|
+
return {
|
|
681
|
+
window_days: windowDays,
|
|
682
|
+
reviews: runs.length,
|
|
683
|
+
floor: QUALITY_FLOOR,
|
|
684
|
+
below_floor: runs.length < QUALITY_FLOOR,
|
|
685
|
+
findings: orc.length,
|
|
686
|
+
categories,
|
|
687
|
+
gotchas,
|
|
688
|
+
p3_per_review: runs.length ? Math.round((p3 / runs.length) * 100) / 100 : null,
|
|
689
|
+
trend: { first_half: first, second_half: second, direction: first === null || second === null ? null : second > first ? "up" : second < first ? "down" : "flat" },
|
|
690
|
+
// v2.0.0 W7 — the Behaviour panel's review-quality tab. The target, the
|
|
691
|
+
// bands and the floor sentence are the CLI's, so the panel computes none.
|
|
692
|
+
target: QUALITY_TARGET,
|
|
693
|
+
bands: { ok: QUALITY_TARGET, warn: NOISE_ACCEPTANCE },
|
|
694
|
+
series: reviewSeries(orc),
|
|
695
|
+
floor_line: runs.length < QUALITY_FLOOR ? `${QUALITY_FLOOR} reviews needed — ${runs.length} so far.` : null,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
// One point per review (run), oldest first, the last 12: its acceptance
|
|
699
|
+
// (null when nothing in it was decided) and its advice-only (P3) count.
|
|
700
|
+
function reviewSeries(orc) {
|
|
701
|
+
const by = new Map();
|
|
702
|
+
for (const o of orc) {
|
|
703
|
+
if (!o.run) continue;
|
|
704
|
+
const t = dateMs(o.at);
|
|
705
|
+
const r = by.get(o.run) || { run: o.run, at: Number.isFinite(t) ? t : 0, list: [] };
|
|
706
|
+
if (Number.isFinite(t) && t > r.at) r.at = t;
|
|
707
|
+
r.list.push(o);
|
|
708
|
+
by.set(o.run, r);
|
|
709
|
+
}
|
|
710
|
+
return [...by.values()]
|
|
711
|
+
.sort((a, b) => a.at - b.at || a.run.localeCompare(b.run))
|
|
712
|
+
.slice(-QUALITY_SERIES)
|
|
713
|
+
.map((r) => {
|
|
714
|
+
const d = r.list.filter((o) => ["addressed", "disputed", "wontfix"].includes(o.outcome));
|
|
715
|
+
return {
|
|
716
|
+
run: r.run,
|
|
717
|
+
at: r.at ? new Date(r.at).toISOString() : null,
|
|
718
|
+
findings: r.list.length,
|
|
719
|
+
acceptance: d.length ? Math.round((d.filter((o) => o.outcome === "addressed").length / d.length) * 1000) / 1000 : null,
|
|
720
|
+
p3: r.list.filter((o) => o.severity === "P3").length,
|
|
721
|
+
};
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ── The panel view of the ledger (v2.0.0 W7) ────────────────────────────────
|
|
726
|
+
// `orc gotcha list --json` carries this beside the v1 rows: each entry's source,
|
|
727
|
+
// its computed status and a 7-week hits line, plus the sync state as it was
|
|
728
|
+
// last written. It READS the sync file; it never runs a sync.
|
|
729
|
+
const STATUSES = ["active", "candidate", "quiet", "orphaned"];
|
|
730
|
+
function panelView(claudeDir, deps, entries) {
|
|
731
|
+
const repoRoot = deps.repoRootOf(claudeDir);
|
|
732
|
+
const store = makeStore(claudeDir, deps);
|
|
733
|
+
const obs = store.readObs();
|
|
734
|
+
const now = Date.now();
|
|
735
|
+
const tracked = trackedFiles(repoRoot);
|
|
736
|
+
const rows = entries.map((e) => {
|
|
737
|
+
const v = entryView(e);
|
|
738
|
+
const why = orphanReason(v, tracked, repoRoot);
|
|
739
|
+
const status = why ? "orphaned" : isQuiet(v, now) ? "quiet" : "active";
|
|
740
|
+
const weeks = Array(7).fill(0);
|
|
741
|
+
for (const o of obs) {
|
|
742
|
+
if (!linksTo(o, v) && o.gotcha !== v.id) continue;
|
|
743
|
+
const t = dateMs(o.at);
|
|
744
|
+
if (!Number.isFinite(t)) continue;
|
|
745
|
+
const w = Math.floor((now - t) / (7 * DAY));
|
|
746
|
+
if (w >= 0 && w < 7) weeks[6 - w]++;
|
|
747
|
+
}
|
|
748
|
+
return { id: v.id, source: v.source, status, status_reason: why, rule: v.rule, scope: v.scope, fix: v.fix, symptom: v.symptom, polarity: v.polarity, origin: v.origin, weeks };
|
|
749
|
+
});
|
|
750
|
+
let cands = [];
|
|
751
|
+
try {
|
|
752
|
+
cands = candidates(entries, obs, now);
|
|
753
|
+
} catch (_) {}
|
|
754
|
+
let sync = null;
|
|
755
|
+
try {
|
|
756
|
+
const s = JSON.parse(fs.readFileSync(path.join(claudeDir, "orc", "gotchas-sync.json"), "utf8"));
|
|
757
|
+
sync = {
|
|
758
|
+
last_sync: s.last_sync || null,
|
|
759
|
+
sources: Object.entries(s.sources || {}).map(([name, c]) => ({
|
|
760
|
+
name,
|
|
761
|
+
state: c && c.last_error ? (/timed out/.test(c.last_error) ? "timed out" : "failed") : c && c.last_ok ? "ok" : "not configured",
|
|
762
|
+
last_ok: (c && c.last_ok) || null,
|
|
763
|
+
last_error: (c && c.last_error) || null,
|
|
764
|
+
})),
|
|
765
|
+
};
|
|
766
|
+
} catch (_) {}
|
|
767
|
+
const bySource = {};
|
|
768
|
+
for (const r of rows) bySource[r.source] = (bySource[r.source] || 0) + 1;
|
|
769
|
+
const status_counts = Object.fromEntries(STATUSES.map((s) => [s, s === "candidate" ? cands.filter((c) => c.kind === "candidate").length : rows.filter((r) => r.status === s).length]));
|
|
770
|
+
return {
|
|
771
|
+
sources: Object.keys(SOURCE_KIND),
|
|
772
|
+
statuses: STATUSES,
|
|
773
|
+
by_source: Object.keys(SOURCE_KIND).filter((s) => bySource[s]).map((s) => ({ source: s, n: bySource[s] })),
|
|
774
|
+
status_counts,
|
|
775
|
+
rows,
|
|
776
|
+
sync,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ── Observations: validate one body, record a batch (W6a) ────────────────────
|
|
781
|
+
// `observe -` and every importer (`bin/gotcha-import.js`) go through these two,
|
|
782
|
+
// so there is ONE writer of observations.jsonl and ONE promotion pass.
|
|
783
|
+
// A body → { o } or { field, err }. Pure: no file is read or written.
|
|
784
|
+
function normalizeObservation(b, now) {
|
|
785
|
+
const bad = (field, err) => ({ field, err });
|
|
786
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) return bad("body", "must be one JSON object");
|
|
787
|
+
if (!SOURCE_KIND[b.source]) return bad("source", `must be one of: ${Object.keys(SOURCE_KIND).join(", ")}`);
|
|
788
|
+
if (!b.ref || typeof b.ref !== "string") return bad("ref", "is required (e.g. \"PR 142 · thread 3\")");
|
|
789
|
+
if (!OUTCOMES.includes(b.outcome)) return bad("outcome", `must be one of: ${OUTCOMES.join(", ")}`);
|
|
790
|
+
if (!AUTHORS.includes(b.author)) return bad("author", `must be one of: ${AUTHORS.join(", ")}`);
|
|
791
|
+
if (b.category !== undefined && b.category !== null && !CATEGORIES.includes(b.category)) return bad("category", `must be one of: ${CATEGORIES.join(", ")}`);
|
|
792
|
+
if (b.severity !== undefined && b.severity !== null && !SEVERITIES.includes(b.severity)) return bad("severity", "must be P0, P1, P2 or P3");
|
|
793
|
+
if (b.cwe !== undefined && b.cwe !== null && !/^CWE-\d+$/.test(b.cwe)) return bad("cwe", "must be CWE-<number>");
|
|
794
|
+
if (b.gotcha !== undefined && b.gotcha !== null && !/^G-\d{3}$/.test(b.gotcha)) return bad("gotcha", "must be a G-### id");
|
|
795
|
+
const sig = b.sig || b.text ? normalizeSig(b.sig || b.text) : null;
|
|
796
|
+
if (!b.rule && !sig) return bad("rule", "or `sig` is required (a stable rule id, or the finding text)");
|
|
797
|
+
const at = b.at && Number.isFinite(dateMs(b.at)) ? String(b.at).slice(0, 10) : isoDay(new Date(now));
|
|
798
|
+
const o = {
|
|
799
|
+
obs: sha1(`${b.source}|${b.ref}`),
|
|
800
|
+
source: b.source,
|
|
801
|
+
// No names (§8): the ref is kept only as the caller's structured locator.
|
|
802
|
+
ref: oneLine(b.ref).slice(0, 120),
|
|
803
|
+
commit: b.commit ? oneLine(b.commit).slice(0, 40) : null,
|
|
804
|
+
at,
|
|
805
|
+
rule: b.rule ? oneLine(b.rule) : null,
|
|
806
|
+
sig: b.rule ? sig || null : sig,
|
|
807
|
+
category: b.category || null,
|
|
808
|
+
severity: b.severity || null,
|
|
809
|
+
path: b.path ? posix(b.path) : null,
|
|
810
|
+
lines: Array.isArray(b.lines) ? b.lines.filter((n) => Number.isInteger(n)).slice(0, 2) : null,
|
|
811
|
+
lang: b.lang ? oneLine(b.lang) : null,
|
|
812
|
+
outcome: b.outcome,
|
|
813
|
+
author: b.author,
|
|
814
|
+
run: b.run ? oneLine(b.run) : null,
|
|
815
|
+
};
|
|
816
|
+
if (b.pr !== undefined && b.pr !== null) o.pr = String(b.pr);
|
|
817
|
+
if (b.cwe) o.cwe = b.cwe;
|
|
818
|
+
if (b.impact) o.impact = String(b.impact).toUpperCase();
|
|
819
|
+
if (b.reproduced === true) o.reproduced = true;
|
|
820
|
+
if (b.miss === true) o.miss = true;
|
|
821
|
+
if (b.gotcha) o.gotcha = b.gotcha;
|
|
822
|
+
return { o };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Append each normalized observation, bump what an active entry already covers,
|
|
826
|
+
// then run promotion ONCE. `skipUnchanged`: an importer re-reading the same
|
|
827
|
+
// source never appends a line whose outcome did not move (the file stays small).
|
|
828
|
+
function recordObservations(store, led, list, now, opts) {
|
|
829
|
+
const skipUnchanged = !!(opts && opts.skipUnchanged);
|
|
830
|
+
const known = new Map(store.readObs().map((x) => [x.obs, x]));
|
|
831
|
+
const changes = { bumped: [], promoted: [], helpful: [], harmful: [] };
|
|
832
|
+
const recorded = [];
|
|
833
|
+
let unchanged = 0;
|
|
834
|
+
for (const o of list) {
|
|
835
|
+
const prev = known.get(o.obs) || null;
|
|
836
|
+
if (skipUnchanged && prev && prev.outcome === o.outcome) {
|
|
837
|
+
unchanged++;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
store.appendObs(o);
|
|
841
|
+
known.set(o.obs, o);
|
|
842
|
+
recorded.push({ o, prev });
|
|
843
|
+
const firstAddressed = o.outcome === "addressed" && !(prev && prev.outcome === "addressed");
|
|
844
|
+
// A citation of a gotcha by ORC's reviewer: the ACE / ExpeL counters.
|
|
845
|
+
if (o.gotcha && (!prev || prev.outcome !== o.outcome)) {
|
|
846
|
+
const e = led.entries.find((x) => x.id === o.gotcha);
|
|
847
|
+
if (e && o.outcome === "addressed") {
|
|
848
|
+
bumpEntry(e, { by: 0, helpful: 1 });
|
|
849
|
+
changes.helpful.push(e.id);
|
|
850
|
+
} else if (e && o.outcome === "disputed") {
|
|
851
|
+
bumpEntry(e, { by: 0, harmful: 1 });
|
|
852
|
+
changes.harmful.push(e.id);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// A mined case an ACTIVE entry already covers: bump it, never a second entry.
|
|
856
|
+
const linked = isMined(o) ? led.entries.find((e) => entryView(e).polarity === "flag" && linksTo(o, entryView(e))) : null;
|
|
857
|
+
if (linked && firstAddressed) {
|
|
858
|
+
bumpEntry(linked, { seen: dmyUtc(dateMs(o.at)), evidence: o.path ? [o.path + (o.lines && o.lines.length ? ":" + o.lines[0] : "")] : [] });
|
|
859
|
+
changes.bumped.push(linked.id);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// Promotion — computed, and it names why in `origin`.
|
|
863
|
+
const all = store.readObs();
|
|
864
|
+
if (recorded.length) {
|
|
865
|
+
for (const c of candidates(led.entries, all, now).filter((x) => x.promote)) {
|
|
866
|
+
const body = entryFromCandidate(c, `promoted (${c.why})`, now);
|
|
867
|
+
const e = appendEntry(store, led, body);
|
|
868
|
+
if (e) changes.promoted.push({ candidate: c.id, id: e.id, rule: c.promote_rule, why: c.why });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (changes.bumped.length || changes.promoted.length || changes.helpful.length || changes.harmful.length) store.save(led);
|
|
872
|
+
return { recorded, unchanged, changes, all };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ── The CLI ─────────────────────────────────────────────────────────────────
|
|
876
|
+
const USAGE =
|
|
877
|
+
"Usage: orc gotcha add <file|-> add one entry (dedupes; exit 0 added · 3 bumped · 2 malformed)\n" +
|
|
878
|
+
" orc gotcha match --files <csv> the executor block (exit 0 · 1 no match · 4 off)\n" +
|
|
879
|
+
" orc gotcha card --files <csv> [--lane <l>] [--full] the reviewer card (exit 0 · 1 no match)\n" +
|
|
880
|
+
" orc gotcha filter --findings <file|-> drop suppressed / folded / noisy advice; never a P0 or P1\n" +
|
|
881
|
+
" orc gotcha observe <file|-> append one observation, then run promotion (exit 0 · 2 malformed)\n" +
|
|
882
|
+
" orc gotcha list --candidates computed candidates and proposed suppressions (exit 0 · 1 none)\n" +
|
|
883
|
+
" orc gotcha accept <C-id> a person promotes a candidate (exit 0 · 2 unknown)\n" +
|
|
884
|
+
" orc gotcha quality [--window <days>] acceptance per category (exit 0 · 1 below the 5-review floor)\n" +
|
|
885
|
+
" orc gotcha why <G-id|C-id> every observation behind it (exit 0 · 2 unknown)\n" +
|
|
886
|
+
" orc gotcha export --review-md the active entries as a REVIEW.md (exit 0 · 1 none)\n" +
|
|
887
|
+
" orc gotcha import sarif <file> SARIF 2.1.0 → observations (exit 0 · 2 not SARIF)\n" +
|
|
888
|
+
" orc gotcha import sonar [--url U --project K] [--pr N|--branch B] [--org O] [--file <saved answer>]\n" +
|
|
889
|
+
" Sonar issues → observations; token from SONAR_TOKEN only (exit 0 · 2 · 5 auth · 6 network)\n" +
|
|
890
|
+
" orc gotcha import pr <n> PR review threads (humans only) → observations (exit 0 · 5 gh missing/not authed)\n" +
|
|
891
|
+
" orc gotcha import issues [--label bug] closed defects with a closing PR → observations (exit 0 · 5)\n" +
|
|
892
|
+
" orc gotcha sync [--force] every available source, incremental, time-boxed (exit 0 synced · 1 nothing new · 6 a source failed)";
|
|
893
|
+
|
|
894
|
+
function gotchaCmd(deps, sub) {
|
|
895
|
+
const { emitJson, wantsJson, args } = deps;
|
|
896
|
+
const asJson = wantsJson();
|
|
897
|
+
const claudeDir = deps.resolveClaudeDir();
|
|
898
|
+
const repoRoot = deps.repoRootOf(claudeDir);
|
|
899
|
+
const store = makeStore(claudeDir, deps);
|
|
900
|
+
const now = Date.now();
|
|
901
|
+
const valueOf = (name) => {
|
|
902
|
+
const i = args.indexOf(name);
|
|
903
|
+
if (i === -1) return undefined;
|
|
904
|
+
const v = args[i + 1];
|
|
905
|
+
return v !== undefined && (v === "-" || !v.startsWith("--")) ? v : "";
|
|
906
|
+
};
|
|
907
|
+
const pos = deps.positionals();
|
|
908
|
+
const out = (obj, code, human) => {
|
|
909
|
+
if (asJson) emitJson(obj, code);
|
|
910
|
+
if (human) human();
|
|
911
|
+
process.exit(code);
|
|
912
|
+
};
|
|
913
|
+
const fail = (code, reason, message, extra) =>
|
|
914
|
+
out(Object.assign({ ok: false, reason }, extra || {}, { message }), code, () => console.error(message));
|
|
915
|
+
const readInput = (src) => {
|
|
916
|
+
if (src === undefined || src === "" || src === true) return { err: "names no input — pass a file path or - for stdin" };
|
|
917
|
+
try {
|
|
918
|
+
const text = src === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(path.resolve(String(src)), "utf8");
|
|
919
|
+
return { value: JSON.parse(text.replace(/^/, "")) };
|
|
920
|
+
} catch (e) {
|
|
921
|
+
return { err: `is not valid JSON (${e.message.split("\n")[0]})` };
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
const cfg = () => deps.readOverride(claudeDir).map;
|
|
925
|
+
const filesArg = () =>
|
|
926
|
+
String(valueOf("--files") || "")
|
|
927
|
+
.split(",")
|
|
928
|
+
.map((f) => posix(f.trim()))
|
|
929
|
+
.filter(Boolean);
|
|
930
|
+
|
|
931
|
+
// ── add ──
|
|
932
|
+
if (sub === "add") {
|
|
933
|
+
const inp = readInput(pos[2] || (args.includes("-") ? "-" : undefined));
|
|
934
|
+
if (inp.err) return fail(2, "malformed", `gotcha add: the input ${inp.err}`, { field: "body" });
|
|
935
|
+
const v = validateAdd(inp.value);
|
|
936
|
+
if (v.err) return fail(2, "malformed", `gotcha add: field \`${v.field}\` ${v.err}`, { field: v.field });
|
|
937
|
+
const led = store.load();
|
|
938
|
+
const dup = findDuplicate(led.entries, v.body);
|
|
939
|
+
if (dup) {
|
|
940
|
+
const ev = v.body.fields.evidence ? String(v.body.fields.evidence).split(/\s+·\s+/) : [];
|
|
941
|
+
bumpEntry(dup, { by: Number(v.body.fields.hits) || 1, seen: v.body.fields.last_seen, evidence: ev });
|
|
942
|
+
store.save(led);
|
|
943
|
+
return out({ ok: true, action: "bumped", id: dup.id, hits: dup.hits, last_seen: dup.fields.last_seen, file: store.file, text: dup.text }, 3, () =>
|
|
944
|
+
console.log(`✓ ${dup.id} already covers this — hits ${dup.hits}, last seen ${dup.fields.last_seen}. Nothing new was added.`)
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
const e = appendEntry(store, led, v.body);
|
|
948
|
+
if (!e) return fail(2, "ids-exhausted", "gotcha add: G-999 is the last id the heading allows — archive with `orc gotcha prune`.", { field: "id" });
|
|
949
|
+
store.save(led);
|
|
950
|
+
return out({ ok: true, action: "added", id: e.id, hits: e.hits, last_seen: e.fields.last_seen, file: store.file, text: e.text }, 0, () =>
|
|
951
|
+
console.log(`✓ added ${e.id} · ${e.area} · ${e.kind} → ${store.file}`)
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const led = store.load();
|
|
956
|
+
const views = led.entries.map(entryView);
|
|
957
|
+
const obs = store.readObs();
|
|
958
|
+
|
|
959
|
+
// ── match — the executor block, the v1 rule ──
|
|
960
|
+
if (sub === "match") {
|
|
961
|
+
const files = filesArg();
|
|
962
|
+
if (!files.length) return fail(2, "usage", "gotcha match: --files <csv> is required");
|
|
963
|
+
// The v0.40.0 key stays for compatibility (DE-27): a 1.x config that says
|
|
964
|
+
// `gotchas: off` is never silently overridden for the EXECUTOR slice. The
|
|
965
|
+
// reviewer card has no off answer (DE-10).
|
|
966
|
+
if (String(cfg().gotchas) === "off")
|
|
967
|
+
return out({ ok: true, off: true, text: "", ids: [], matched: 0, known: views.length }, 4, () => console.log("gotchas: off — no executor block"));
|
|
968
|
+
const hit = views.filter((v) => v.polarity === "flag" && doLine(v, obs) && files.some((f) => inScope(v.scope, f)));
|
|
969
|
+
hit.sort((a, b) => b.hits - a.hits || dateMs(b.last_seen) - dateMs(a.last_seen));
|
|
970
|
+
const top = hit.slice(0, MATCH_CAP);
|
|
971
|
+
const text = top.map((v) => v.raw.text).join("\n\n");
|
|
972
|
+
return out({ ok: true, text, ids: top.map((v) => v.id), matched: hit.length, known: views.length }, top.length ? 0 : 1, () =>
|
|
973
|
+
console.log(top.length ? text : `no gotcha matches these files (${views.length} known) — no block`)
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ── card — the reviewer card, ALWAYS built when anything matches ──
|
|
978
|
+
if (sub === "card") {
|
|
979
|
+
const files = filesArg();
|
|
980
|
+
if (!files.length) return fail(2, "usage", "gotcha card: --files <csv> is required");
|
|
981
|
+
const lane = valueOf("--lane") || null;
|
|
982
|
+
const budget = Math.max(CARD_BUDGET_MIN, Number(cfg().gotcha_card_budget) || CARD_BUDGET_DEFAULT);
|
|
983
|
+
const tracked = trackedFiles(repoRoot);
|
|
984
|
+
const orphaned = [];
|
|
985
|
+
let quiet = 0;
|
|
986
|
+
const rows = [];
|
|
987
|
+
for (const v of views) {
|
|
988
|
+
if (!files.some((f) => inScope(v.scope, f))) continue;
|
|
989
|
+
const why = orphanReason(v, tracked, repoRoot);
|
|
990
|
+
if (why) {
|
|
991
|
+
orphaned.push({ id: v.id, why });
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
if (isQuiet(v, now)) {
|
|
995
|
+
quiet++;
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
const miss = obs.some((o) => o.miss === true && linksTo(o, v));
|
|
999
|
+
if (v.polarity === "suppress") rows.push({ id: v.id, type: "suppress", text: suppressLine(v), rank: rankOf(v, now), first: false });
|
|
1000
|
+
else rows.push({ id: v.id, type: "flag", text: lineFor(v), rank: rankOf(v, now), first: miss || v.category === "security", miss });
|
|
1001
|
+
}
|
|
1002
|
+
const cands = candidates(led.entries, obs, now).filter((c) => c.watch && files.some((f) => inScope(c.scope, f)));
|
|
1003
|
+
for (const c of cands)
|
|
1004
|
+
rows.push({ id: c.id, type: "watch", text: watchLine(c), rank: (SEV_W[c.severity] || 2) * Math.log(1 + c.addressed) * specificity(c.scope), first: false });
|
|
1005
|
+
const byRank = (a, b) => (b.first ? 1 : 0) - (a.first ? 1 : 0) || b.rank - a.rank || a.id.localeCompare(b.id);
|
|
1006
|
+
const ordered = rows.filter((r) => r.type === "flag").sort(byRank)
|
|
1007
|
+
.concat(rows.filter((r) => r.type === "watch").sort(byRank))
|
|
1008
|
+
.concat(rows.filter((r) => r.type === "suppress").sort(byRank));
|
|
1009
|
+
const matched = ordered.length;
|
|
1010
|
+
if (!matched)
|
|
1011
|
+
return out({ ok: true, lane, text: "", ids: [], matched: 0, known: views.length, dropped: 0, orphaned: orphaned.map((o) => o.id), quiet, budget, tokens: 0 }, 1, () =>
|
|
1012
|
+
console.log(`no gotcha matches these files (${views.length} known) — no card`)
|
|
1013
|
+
);
|
|
1014
|
+
const kept = [];
|
|
1015
|
+
let nFlag = 0;
|
|
1016
|
+
let nWatch = 0;
|
|
1017
|
+
let nSup = 0;
|
|
1018
|
+
const headerFor = (n, d) => `gotchas (${n} of ${views.length} match${d ? ` · ${d} dropped by budget` : ""}):`;
|
|
1019
|
+
for (const r of ordered) {
|
|
1020
|
+
if (r.type === "watch" && nWatch >= CARD_WATCH_CAP) {
|
|
1021
|
+
r.dropped = "watch cap";
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (r.type !== "suppress" && nFlag + nWatch >= CARD_FLAG_CAP) {
|
|
1025
|
+
r.dropped = "line cap";
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
if (r.type === "suppress" && nSup >= CARD_SUPPRESS_CAP) {
|
|
1029
|
+
r.dropped = "suppress cap";
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const trial = [headerFor(matched, matched - kept.length - 1)].concat(kept.map((k) => k.text), r.text).join("\n");
|
|
1033
|
+
if (estTokens(trial) > budget) {
|
|
1034
|
+
r.dropped = "token budget";
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
kept.push(r);
|
|
1038
|
+
if (r.type === "flag") nFlag++;
|
|
1039
|
+
else if (r.type === "watch") nWatch++;
|
|
1040
|
+
else nSup++;
|
|
1041
|
+
}
|
|
1042
|
+
const dropped = matched - kept.length;
|
|
1043
|
+
const text = [headerFor(matched, dropped)].concat(kept.map((k) => k.text)).join("\n");
|
|
1044
|
+
const payload = {
|
|
1045
|
+
ok: true,
|
|
1046
|
+
lane,
|
|
1047
|
+
text,
|
|
1048
|
+
ids: kept.map((k) => k.id),
|
|
1049
|
+
matched,
|
|
1050
|
+
known: views.length,
|
|
1051
|
+
dropped,
|
|
1052
|
+
orphaned: orphaned.map((o) => o.id),
|
|
1053
|
+
quiet,
|
|
1054
|
+
budget,
|
|
1055
|
+
tokens: estTokens(text),
|
|
1056
|
+
};
|
|
1057
|
+
// BRIEF by default (T17 b): `text` + counts. `--full` adds every row.
|
|
1058
|
+
if (args.includes("--full"))
|
|
1059
|
+
Object.assign(payload, {
|
|
1060
|
+
rows: ordered.map((r) => ({ id: r.id, type: r.type, text: r.text, rank: Math.round(r.rank * 1000) / 1000, miss: !!r.miss, kept: !r.dropped, dropped: r.dropped || null })),
|
|
1061
|
+
orphaned_rows: orphaned,
|
|
1062
|
+
});
|
|
1063
|
+
return out(payload, 0, () => {
|
|
1064
|
+
console.log(text);
|
|
1065
|
+
if (orphaned.length) console.log(` (${orphaned.length} orphaned, left out: ${orphaned.map((o) => o.id).join(", ")})`);
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// ── filter — after the review; NEVER touches a P0 or P1 ──
|
|
1070
|
+
if (sub === "filter") {
|
|
1071
|
+
const inp = readInput(valueOf("--findings"));
|
|
1072
|
+
if (inp.err) return fail(2, "malformed", `gotcha filter: --findings ${inp.err}`, { field: "findings" });
|
|
1073
|
+
const list = Array.isArray(inp.value) ? inp.value : inp.value && Array.isArray(inp.value.findings) ? inp.value.findings : null;
|
|
1074
|
+
if (!list) return fail(2, "malformed", "gotcha filter: --findings must be a JSON array (or {findings: [...]})", { field: "findings" });
|
|
1075
|
+
const sevOf = (f) => String((f && (f.severity || f.priority)) || "").toUpperCase();
|
|
1076
|
+
const pathOf = (f) => posix((f && (f.path || f.file)) || "");
|
|
1077
|
+
const gating = (f) => sevOf(f) === "P0" || sevOf(f) === "P1";
|
|
1078
|
+
const removed = [];
|
|
1079
|
+
const suppressed = {};
|
|
1080
|
+
let rest = [];
|
|
1081
|
+
const sup = views.filter((v) => v.polarity === "suppress" && v.category);
|
|
1082
|
+
for (const f of list) {
|
|
1083
|
+
if (!gating(f) && ["P2", "P3"].includes(sevOf(f))) {
|
|
1084
|
+
const by = sup.find((v) => v.category === (f && f.category) && pathOf(f) && inScope(v.scope, pathOf(f)));
|
|
1085
|
+
if (by) {
|
|
1086
|
+
removed.push({ finding: f, by: by.id });
|
|
1087
|
+
suppressed[by.id] = (suppressed[by.id] || 0) + 1;
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
rest.push(f);
|
|
1092
|
+
}
|
|
1093
|
+
// P3 capped at 5; the rest fold into `+N similar`.
|
|
1094
|
+
let p3 = 0;
|
|
1095
|
+
let folded = 0;
|
|
1096
|
+
rest = rest.filter((f) => {
|
|
1097
|
+
if (sevOf(f) !== "P3") return true;
|
|
1098
|
+
p3++;
|
|
1099
|
+
if (p3 <= P3_CAP) return true;
|
|
1100
|
+
folded++;
|
|
1101
|
+
removed.push({ finding: f, by: "fold" });
|
|
1102
|
+
return false;
|
|
1103
|
+
});
|
|
1104
|
+
// The per-category noise budget from `quality`.
|
|
1105
|
+
const q = quality(obs, led.entries, WINDOW_DAYS, now);
|
|
1106
|
+
const noisy = new Set(q.categories.filter((c) => c.noisy).map((c) => c.category));
|
|
1107
|
+
const seenNoisy = {};
|
|
1108
|
+
rest = rest.filter((f) => {
|
|
1109
|
+
const cat = f && f.category;
|
|
1110
|
+
if (gating(f) || !noisy.has(cat)) return true;
|
|
1111
|
+
seenNoisy[cat] = (seenNoisy[cat] || 0) + 1;
|
|
1112
|
+
if (seenNoisy[cat] <= 1) return true;
|
|
1113
|
+
removed.push({ finding: f, by: `noise:${cat}` });
|
|
1114
|
+
return false;
|
|
1115
|
+
});
|
|
1116
|
+
const payload = { ok: true, kept: rest, removed, folded, suppressed, noisy: [...noisy], counts: { in: list.length, kept: rest.length, removed: removed.length } };
|
|
1117
|
+
return out(payload, 0, () => {
|
|
1118
|
+
console.log(`gotcha filter: ${list.length} in · ${rest.length} kept · ${removed.length} removed${folded ? ` (+${folded} similar P3 folded)` : ""}`);
|
|
1119
|
+
for (const r of removed) console.log(` removed by ${r.by}: ${oneLine((r.finding && (r.finding.title || r.finding.summary || r.finding.id)) || "(finding)")}`);
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ── observe — append one observation, then promotion ──
|
|
1124
|
+
if (sub === "observe") {
|
|
1125
|
+
const inp = readInput(pos[2] || (args.includes("-") ? "-" : undefined));
|
|
1126
|
+
if (inp.err) return fail(2, "malformed", `gotcha observe: the input ${inp.err}`, { field: "body" });
|
|
1127
|
+
const n = normalizeObservation(inp.value, now);
|
|
1128
|
+
if (n.err) return fail(2, "malformed", `gotcha observe: field \`${n.field}\` ${n.err}`, { field: n.field });
|
|
1129
|
+
const o = n.o;
|
|
1130
|
+
const { recorded, changes, all } = recordObservations(store, led, [o], now);
|
|
1131
|
+
const prev = recorded[0].prev;
|
|
1132
|
+
const after = candidates(led.entries, all, now);
|
|
1133
|
+
const mine = after.find((c) => c.obs.includes(o.obs));
|
|
1134
|
+
const payload = {
|
|
1135
|
+
ok: true,
|
|
1136
|
+
obs: o.obs,
|
|
1137
|
+
replaced: !!prev,
|
|
1138
|
+
observation: o,
|
|
1139
|
+
candidate: mine ? publicCand(mine) : null,
|
|
1140
|
+
watch: !!(mine && mine.watch),
|
|
1141
|
+
bumped: changes.bumped,
|
|
1142
|
+
promoted: changes.promoted,
|
|
1143
|
+
helpful: changes.helpful,
|
|
1144
|
+
harmful: changes.harmful,
|
|
1145
|
+
file: store.obsFile,
|
|
1146
|
+
};
|
|
1147
|
+
return out(payload, 0, () => {
|
|
1148
|
+
console.log(`✓ observed ${o.obs.slice(0, 8)} (${o.source} · ${o.outcome})${prev ? " — replaces the earlier line" : ""}`);
|
|
1149
|
+
for (const p of changes.promoted) console.log(` promoted ${p.candidate} → ${p.id}: ${p.why}`);
|
|
1150
|
+
for (const id of changes.bumped) console.log(` bumped ${id}`);
|
|
1151
|
+
if (mine) console.log(` candidate ${mine.id}: ${mine.needs || "ready"}`);
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// ── list --candidates ──
|
|
1156
|
+
if (sub === "list" && args.includes("--candidates")) {
|
|
1157
|
+
const cands = candidates(led.entries, obs, now).map(publicCand);
|
|
1158
|
+
const tracked = trackedFiles(repoRoot);
|
|
1159
|
+
const orphaned = views.map((v) => ({ id: v.id, why: orphanReason(v, tracked, repoRoot) })).filter((x) => x.why);
|
|
1160
|
+
const quiet = views.filter((v) => isQuiet(v, now)).map((v) => v.id);
|
|
1161
|
+
const retire = views.filter((v) => v.harmful >= 3 && v.harmful >= v.helpful).map((v) => ({ id: v.id, helpful: v.helpful, harmful: v.harmful, proposal: "retire — ORC never applies it; delete the block yourself" }));
|
|
1162
|
+
const payload = {
|
|
1163
|
+
ok: true,
|
|
1164
|
+
count: cands.length,
|
|
1165
|
+
candidates: cands.filter((c) => c.kind === "candidate"),
|
|
1166
|
+
suppressions: cands.filter((c) => c.kind === "suppression"),
|
|
1167
|
+
project_actions: cands.filter((c) => c.kind === "project-action"),
|
|
1168
|
+
retire,
|
|
1169
|
+
quiet,
|
|
1170
|
+
orphaned,
|
|
1171
|
+
observations: obs.length,
|
|
1172
|
+
rules: { cases: PROMOTE_CASES, prs: PROMOTE_PRS, window_days: WINDOW_DAYS, jaccard_min: JACCARD_MIN, suppress_disputes: SUPPRESS_DISPUTES, do_line_cases: DO_LINE_CASES },
|
|
1173
|
+
};
|
|
1174
|
+
return out(payload, cands.length ? 0 : 1, () => {
|
|
1175
|
+
if (!cands.length) console.log(`no candidates (${obs.length} observation${obs.length === 1 ? "" : "s"})`);
|
|
1176
|
+
for (const c of cands) console.log(` ${c.id} · ${c.kind} · ${c.rule || c.sig} · ${c.scope || "(no scope)"} · ${c.addressed} addressed / ${c.disputed} disputed\n ${c.needs || "ready"}`);
|
|
1177
|
+
for (const r of retire) console.log(` retire? ${r.id} — harmful ${r.harmful}, helpful ${r.helpful}`);
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// ── accept <C-id> ──
|
|
1182
|
+
if (sub === "accept") {
|
|
1183
|
+
const id = String(pos[2] || "").trim();
|
|
1184
|
+
const c = candidates(led.entries, obs, now).find((x) => x.id === id);
|
|
1185
|
+
if (!c) return fail(2, "unknown", `gotcha accept: no candidate "${id}". See: orc gotcha list --candidates`, { id: id || null });
|
|
1186
|
+
if (c.kind === "project-action") return fail(2, "lint-rule", `gotcha accept: ${id} is a lint rule — turn the linter rule on (${c.rule}); it is never a review item`, { id });
|
|
1187
|
+
if (!c.scope) return fail(2, "no-scope", `gotcha accept: ${id} has no scope — ${c.scope_refused}`, { id });
|
|
1188
|
+
const e = appendEntry(store, led, entryFromCandidate(c, "accepted by a person", now));
|
|
1189
|
+
if (!e) return fail(2, "ids-exhausted", "gotcha accept: G-999 is the last id the heading allows.", { id });
|
|
1190
|
+
store.save(led);
|
|
1191
|
+
return out({ ok: true, candidate: id, id: e.id, polarity: e.fields.polarity, file: store.file, text: e.text }, 0, () => console.log(`✓ ${id} → ${e.id} (${e.fields.polarity})`));
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ── quality ──
|
|
1195
|
+
if (sub === "quality") {
|
|
1196
|
+
const w = Number(valueOf("--window"));
|
|
1197
|
+
const windowDays = Number.isInteger(w) && w > 0 ? w : WINDOW_DAYS;
|
|
1198
|
+
const q = quality(obs, led.entries, windowDays, now);
|
|
1199
|
+
return out(Object.assign({ ok: true }, q), q.below_floor ? 1 : 0, () => {
|
|
1200
|
+
if (q.below_floor) console.log(`gotcha quality: ${q.reviews} review(s) in ${windowDays} days — below the ${QUALITY_FLOOR}-review floor, no answer yet`);
|
|
1201
|
+
for (const c of q.categories) console.log(` ${c.category.padEnd(28)} ${c.findings} findings · acceptance ${c.acceptance === null ? "—" : Math.round(c.acceptance * 100) + " %"}${c.noisy ? " · noisy" : ""}`);
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// ── why <G-id|C-id> ──
|
|
1206
|
+
if (sub === "why") {
|
|
1207
|
+
const id = String(pos[2] || "").trim().toUpperCase();
|
|
1208
|
+
let list = null;
|
|
1209
|
+
let kind = null;
|
|
1210
|
+
const v = views.find((x) => x.id === id);
|
|
1211
|
+
if (v) {
|
|
1212
|
+
kind = "gotcha";
|
|
1213
|
+
list = obs.filter((o) => o.gotcha === id || linksTo(o, v));
|
|
1214
|
+
} else {
|
|
1215
|
+
const c = candidates(led.entries, obs, now).find((x) => x.id.toUpperCase() === id);
|
|
1216
|
+
if (c) {
|
|
1217
|
+
kind = c.kind;
|
|
1218
|
+
list = c._cases;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
if (!list) return fail(2, "unknown", `gotcha why: no gotcha or candidate "${pos[2] || ""}"`, { id: pos[2] || null });
|
|
1222
|
+
return out({ ok: true, id, kind, count: list.length, observations: list, origin: v ? v.origin : null }, 0, () => {
|
|
1223
|
+
console.log(`${id} (${kind}) — ${list.length} observation${list.length === 1 ? "" : "s"}${v && v.origin ? " · origin: " + v.origin : ""}`);
|
|
1224
|
+
for (const o of list) console.log(` ${o.at} · ${o.source} · ${o.outcome} · ${o.path || "-"} · ${o.rule || o.sig || ""}`);
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// ── export --review-md (DE-15) ──
|
|
1229
|
+
if (sub === "export") {
|
|
1230
|
+
if (!args.includes("--review-md")) return fail(2, "usage", "gotcha export: only --review-md is supported");
|
|
1231
|
+
const tracked = trackedFiles(repoRoot);
|
|
1232
|
+
const live = views.filter((v) => !orphanReason(v, tracked, repoRoot));
|
|
1233
|
+
const flags = live.filter((v) => v.polarity === "flag").sort((a, b) => b.hits - a.hits || a.id.localeCompare(b.id));
|
|
1234
|
+
const sups = live.filter((v) => v.polarity === "suppress");
|
|
1235
|
+
const md = [
|
|
1236
|
+
"# REVIEW.md — what this project has already gotten wrong",
|
|
1237
|
+
"",
|
|
1238
|
+
"Generated by `orc gotcha export --review-md` from `.claude/orc/gotchas.md`. Do not edit here: edit the ledger and export again.",
|
|
1239
|
+
"",
|
|
1240
|
+
];
|
|
1241
|
+
if (flags.length) md.push("## Always check", "", ...flags.map((v) => `- ${lineFor(v)}`), "");
|
|
1242
|
+
if (sups.length) md.push("## Do not flag", "", ...sups.map((v) => `- ${suppressLine(v)}`), "");
|
|
1243
|
+
const markdown = md.join("\n");
|
|
1244
|
+
const count = flags.length + sups.length;
|
|
1245
|
+
return out({ ok: true, count, ids: flags.concat(sups).map((v) => v.id), markdown }, count ? 0 : 1, () =>
|
|
1246
|
+
process.stdout.write(count ? markdown : "no active gotchas — nothing to export\n")
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
return fail(sub ? 2 : 1, "usage", USAGE);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
module.exports = {
|
|
1254
|
+
KINDS,
|
|
1255
|
+
SOURCE_KIND,
|
|
1256
|
+
V1_SOURCES,
|
|
1257
|
+
IN_LANE_SOURCES,
|
|
1258
|
+
CATEGORIES,
|
|
1259
|
+
REQUIRED,
|
|
1260
|
+
OPTIONAL,
|
|
1261
|
+
JACCARD_MIN,
|
|
1262
|
+
WINDOW_DAYS,
|
|
1263
|
+
CARD_BUDGET_DEFAULT,
|
|
1264
|
+
CARD_BUDGET_MIN,
|
|
1265
|
+
globRe,
|
|
1266
|
+
inScope,
|
|
1267
|
+
deriveScope,
|
|
1268
|
+
normalizeSig,
|
|
1269
|
+
jaccard,
|
|
1270
|
+
promotion,
|
|
1271
|
+
candidates,
|
|
1272
|
+
validateAdd,
|
|
1273
|
+
renderEntry,
|
|
1274
|
+
entryView,
|
|
1275
|
+
quality,
|
|
1276
|
+
panelView,
|
|
1277
|
+
STATUSES,
|
|
1278
|
+
gotchaCmd,
|
|
1279
|
+
USAGE,
|
|
1280
|
+
// W6a — the importers (bin/gotcha-import.js) record through these.
|
|
1281
|
+
makeStore,
|
|
1282
|
+
normalizeObservation,
|
|
1283
|
+
recordObservations,
|
|
1284
|
+
redact,
|
|
1285
|
+
sha1,
|
|
1286
|
+
};
|