@papi-ai/shared
Advanced tools
+120
-1
@@ -0,1 +1,120 @@ | ||
| /** Severity of a parsed finding. 'note' is unprefixed content we refuse to drop. */ | ||
| type DiscoverySeverity = 'P0' | 'P1' | 'P2' | 'P3' | 'note'; | ||
| /** What kind of discovery a finding is. Maps to cycle_learnings.category. */ | ||
| type FindingKind = 'issue' | 'dead_end' | 'surprise'; | ||
| /** Legacy parsed shape, consumed by the dashboard's Discoveries surfaces. */ | ||
| interface Discovery { | ||
| severity: DiscoverySeverity; | ||
| text: string; | ||
| } | ||
| /** A parsed finding, kind-tagged. The shape the server writes to cycle_learnings. */ | ||
| interface ParsedFinding { | ||
| kind: FindingKind; | ||
| severity: DiscoverySeverity; | ||
| summary: string; | ||
| } | ||
| /** True when a free-text report field is empty or a "none"-equivalent. Used to | ||
| * keep `deadEnds` / `surprises` of "none" (or "- none") from rendering or | ||
| * counting as real entries at the content level — the field-level `.trim()` | ||
| * check alone lets the literal string "none" through (task-2485). Strips a | ||
| * leading bullet first so "- none" is caught too. */ | ||
| declare function isNoneText(raw: string | null | undefined): boolean; | ||
| /** | ||
| * Split one free-text report field into kind-tagged findings. | ||
| * | ||
| * This is a verbatim lift of lib/discoveries.parseDiscoveredIssues (task-2068), | ||
| * generalised over `kind`. Every branch below is a past production bug and must | ||
| * not be "simplified": | ||
| * - inline "Pn:" runs on a single line (task-2068) | ||
| * - the bullet-dash "- P0 …" form (task-2485) | ||
| * - lowercase-continuation merging (task-2068) | ||
| * - bulleted "- none" defeating the ^ anchor (task-2485) | ||
| * - the ad_hoc "None — ad-hoc work." placeholder (task-2839) | ||
| * | ||
| * TOP-LEVEL GUARD, deliberately kind-conditional. 'issue' keeps the legacy | ||
| * NONE_RE-only check so the committed parity test against parseDiscoveredIssues | ||
| * holds byte-for-byte. 'surprise' and 'dead_end' additionally reject the ad_hoc | ||
| * placeholder, because that string is what services/ad-hoc.ts writes into the | ||
| * SURPRISES field specifically — those kinds did not flow through this parser | ||
| * before, so there is no legacy behaviour to preserve for them. | ||
| */ | ||
| declare function splitFindings(raw: string | null | undefined, kind: FindingKind): ParsedFinding[]; | ||
| /** | ||
| * Legacy alias preserving the exact shape the dashboard consumes ({severity, text}). | ||
| * Implemented in terms of splitFindings so there is genuinely ONE parser — | ||
| * lib/discoveries.ts re-exports this rather than keeping a copy. | ||
| */ | ||
| declare function parseDiscoveredIssues(raw: string | null | undefined): Discovery[]; | ||
| /** | ||
| * Normalised dedup key for a finding summary. | ||
| * | ||
| * CONTRACT WITH SQL. This MUST stay byte-for-byte identical to | ||
| * public.papi_finding_key(text), created by task-2997's migration | ||
| * (supabase/migrations/20260809000000_per_finding_discovery_schema.sql). If they | ||
| * drift, dedup silently splits into two rows per finding and `occurrences` never | ||
| * increments — a failure that looks exactly like working software. | ||
| * | ||
| * The five steps, in order (regexes written without their enclosing slashes — | ||
| * a trailing `\s*` followed by `/` would close this comment block): | ||
| * 1. strip a leading, case-insensitive severity prefix: ^\s*P[0-3]\s*[:—–-]?\s* | ||
| * 2. lowercase | ||
| * 3. collapse every run of non-alphanumerics to a single space | ||
| * 4. trim | ||
| * 5. take the first 200 characters, trim again | ||
| * | ||
| * SQL returns NULL where this returns '' — callers must treat an empty key as | ||
| * "no key", which is what keeps a keyless row outside the partial unique index. | ||
| * | ||
| * KNOWN LOSSY CASE (flagged in task-2997, not yet decided): step 3 destroys | ||
| * operators, so "Token expiry uses < not <=" keys to "token expiry uses not". | ||
| * Two genuinely different findings about < vs <= collapse into one row. Changing | ||
| * this means changing BOTH sides together, never one. | ||
| */ | ||
| declare function findingKey(summary: string | null | undefined): string; | ||
| /** How a finding was disposed of. Mirrors cycle_learnings.disposition. */ | ||
| type FindingDisposition = 'fixed_now' | 'filed' | 'wont_fix'; | ||
| /** The shape build_execute accepts in its optional `findings[]` array. */ | ||
| interface FindingInput { | ||
| kind?: FindingKind; | ||
| severity?: DiscoverySeverity | string; | ||
| summary: string; | ||
| detail?: string; | ||
| disposition?: FindingDisposition | string; | ||
| reason?: string; | ||
| module?: string; | ||
| } | ||
| interface FindingViolation { | ||
| index: number; | ||
| summary: string; | ||
| rule: 'missing_disposition' | 'filed_low_severity_needs_reason' | 'wont_fix_needs_reason'; | ||
| message: string; | ||
| } | ||
| interface FindingValidation { | ||
| ok: boolean; | ||
| violations: FindingViolation[]; | ||
| } | ||
| /** | ||
| * The minimum length of a disposition reason. "n/a", "todo" and "later" are the | ||
| * predictable evasions and all fall under twelve characters; the floor is what | ||
| * makes filing cost something. Deliberately a constant so the gate's message and | ||
| * the test assert the same number. | ||
| */ | ||
| declare const MIN_REASON_LENGTH = 12; | ||
| /** | ||
| * The fix-or-file gate predicate (design doc §B truth table). Pure — no IO — so | ||
| * task-3001 can wire it into build_execute and the dashboard can reuse it. | ||
| * | ||
| * kind='issue', no disposition, findings supplied -> reject | ||
| * filed + P2/P3 + reason missing or < 12 chars -> reject | ||
| * wont_fix + reason missing or < 12 chars -> reject | ||
| * filed + P0/P1 -> allow | ||
| * fixed_now, any severity -> allow | ||
| * findings absent -> allow (legacy prose path) | ||
| * | ||
| * The legacy allowance is what makes the upgrade non-breaking: an agent that | ||
| * sends only prose keeps working, `splitFindings` runs on the blob, rows land with | ||
| * disposition NULL and the gate never fires. | ||
| */ | ||
| declare function validateFindings(findings?: FindingInput[] | null): FindingValidation; | ||
| /** Valid task statuses on the cycle board. */ | ||
@@ -170,2 +289,2 @@ type TaskStatus = 'Backlog' | 'In Cycle' | 'Ready' | 'In Progress' | 'In Review' | 'Done' | 'Blocked' | 'Cancelled' | 'Deferred'; | ||
| export { CAPABILITY_KEYS, CAPABILITY_REGISTRY, type CapabilityDescriptor, type CapabilityKey, type CapabilityMap, type CapabilityStep, type ContributorGateDecision, type DocDeletionBlockReason, type DocDeletionDecision, type DocDeletionInput, type EffortSize, RETIRED_DECISION_OUTCOMES, type ReviewStage, type ReviewVerdict, SENSITIVE_CHANGELOG_PATTERNS, TASK_STATUSES, type TaskComplexity, type TaskPriority, type TaskStatus, type TaskType, VALID_TRANSITIONS, contributorTeamUpsell, docDeletionBlockMessage, evaluateContributorGate, evaluateDocDeletion, isCapabilityEnabled, isCapabilityKey, isLiveDecision, isSensitiveChangelogLine, isValidStatus, isValidTransition, validateTransition }; | ||
| export { CAPABILITY_KEYS, CAPABILITY_REGISTRY, type CapabilityDescriptor, type CapabilityKey, type CapabilityMap, type CapabilityStep, type ContributorGateDecision, type Discovery, type DiscoverySeverity, type DocDeletionBlockReason, type DocDeletionDecision, type DocDeletionInput, type EffortSize, type FindingDisposition, type FindingInput, type FindingKind, type FindingValidation, type FindingViolation, MIN_REASON_LENGTH, type ParsedFinding, RETIRED_DECISION_OUTCOMES, type ReviewStage, type ReviewVerdict, SENSITIVE_CHANGELOG_PATTERNS, TASK_STATUSES, type TaskComplexity, type TaskPriority, type TaskStatus, type TaskType, VALID_TRANSITIONS, contributorTeamUpsell, docDeletionBlockMessage, evaluateContributorGate, evaluateDocDeletion, findingKey, isCapabilityEnabled, isCapabilityKey, isLiveDecision, isNoneText, isSensitiveChangelogLine, isValidStatus, isValidTransition, parseDiscoveredIssues, splitFindings, validateFindings, validateTransition }; |
+97
-0
@@ -0,1 +1,92 @@ | ||
| // src/findings.ts | ||
| var SEVERITY_ORDER = { | ||
| P0: 0, | ||
| P1: 1, | ||
| P2: 2, | ||
| P3: 3, | ||
| note: 4 | ||
| }; | ||
| var NONE_RE = /^(none|n\/a|no(ne)? (found|discovered))\.?$/i; | ||
| var AD_HOC_PLACEHOLDER_RE = /^none\s*[—–-]\s*ad[-\s]?hoc work\.?$/i; | ||
| function isNoneText(raw) { | ||
| if (!raw) return true; | ||
| const trimmed = raw.replace(/^[-•]\s*/, "").trim(); | ||
| return trimmed.length === 0 || NONE_RE.test(trimmed) || AD_HOC_PLACEHOLDER_RE.test(trimmed); | ||
| } | ||
| function splitFindings(raw, kind) { | ||
| if (!raw) return []; | ||
| const trimmed = raw.trim(); | ||
| if (trimmed.length === 0 || NONE_RE.test(trimmed)) return []; | ||
| if (kind !== "issue" && AD_HOC_PLACEHOLDER_RE.test(trimmed)) return []; | ||
| const chunks = trimmed.split(/\n+/).flatMap((line) => line.split(/(?=\bP[0-3]\s*[:—-])/)).map((s) => s.trim()).filter((s) => s.length > 0 && !NONE_RE.test(s)); | ||
| const items = []; | ||
| for (const chunk of chunks) { | ||
| const m = chunk.match(/^P([0-3])\s*[:—-]\s*(.*)$/s); | ||
| if (m) { | ||
| const summary = m[2].trim(); | ||
| if (summary.length === 0) continue; | ||
| items.push({ kind, severity: `P${m[1]}`, summary }); | ||
| } else { | ||
| const prev = items[items.length - 1]; | ||
| if (prev && /^[a-z(]/.test(chunk)) { | ||
| prev.summary = `${prev.summary} ${chunk}`; | ||
| } else { | ||
| const noteText = chunk.replace(/^[-•]\s*/, "").trim(); | ||
| if (noteText.length === 0 || NONE_RE.test(noteText)) continue; | ||
| const bare = noteText.match(/^P([0-3])\b\s*(.*)$/s); | ||
| if (bare && bare[2].trim().length > 0) { | ||
| items.push({ kind, severity: `P${bare[1]}`, summary: bare[2].trim() }); | ||
| } else { | ||
| items.push({ kind, severity: "note", summary: noteText }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return items.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); | ||
| } | ||
| function parseDiscoveredIssues(raw) { | ||
| return splitFindings(raw, "issue").map((f) => ({ severity: f.severity, text: f.summary })); | ||
| } | ||
| function findingKey(summary) { | ||
| if (!summary) return ""; | ||
| return summary.replace(/^\s*P[0-3]\s*[:—–-]?\s*/i, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().slice(0, 200).trim(); | ||
| } | ||
| var MIN_REASON_LENGTH = 12; | ||
| var LOW_SEVERITIES = /* @__PURE__ */ new Set(["P2", "P3"]); | ||
| function validateFindings(findings) { | ||
| if (!findings || findings.length === 0) return { ok: true, violations: [] }; | ||
| const violations = []; | ||
| findings.forEach((f, index) => { | ||
| const kind = f.kind ?? "issue"; | ||
| const summary = f.summary ?? ""; | ||
| const reason = (f.reason ?? "").trim(); | ||
| if (kind === "issue" && !f.disposition) { | ||
| violations.push({ | ||
| index, | ||
| summary, | ||
| rule: "missing_disposition", | ||
| message: `"${summary}" has no disposition. Use fixed_now if you already fixed it, filed if it needs its own task, or wont_fix if it is not worth fixing.` | ||
| }); | ||
| return; | ||
| } | ||
| if (f.disposition === "filed" && LOW_SEVERITIES.has(String(f.severity)) && reason.length < MIN_REASON_LENGTH) { | ||
| violations.push({ | ||
| index, | ||
| summary, | ||
| rule: "filed_low_severity_needs_reason", | ||
| message: `"${summary}" is a ${f.severity} being filed rather than fixed, which needs a reason of at least ${MIN_REASON_LENGTH} characters. You already have the file open and the context loaded \u2014 fixing costs less than filing, triaging, planning and re-contexting it three cycles from now.` | ||
| }); | ||
| } | ||
| if (f.disposition === "wont_fix" && reason.length < MIN_REASON_LENGTH) { | ||
| violations.push({ | ||
| index, | ||
| summary, | ||
| rule: "wont_fix_needs_reason", | ||
| message: `"${summary}" is marked wont_fix, which needs a reason of at least ${MIN_REASON_LENGTH} characters explaining why it is not worth fixing.` | ||
| }); | ||
| } | ||
| }); | ||
| return { ok: violations.length === 0, violations }; | ||
| } | ||
| // src/index.ts | ||
@@ -160,2 +251,3 @@ var TASK_STATUSES = ["Backlog", "In Cycle", "Ready", "In Progress", "In Review", "Done", "Blocked", "Cancelled", "Deferred"]; | ||
| CAPABILITY_REGISTRY, | ||
| MIN_REASON_LENGTH, | ||
| RETIRED_DECISION_OUTCOMES, | ||
@@ -169,9 +261,14 @@ SENSITIVE_CHANGELOG_PATTERNS, | ||
| evaluateDocDeletion, | ||
| findingKey, | ||
| isCapabilityEnabled, | ||
| isCapabilityKey, | ||
| isLiveDecision, | ||
| isNoneText, | ||
| isSensitiveChangelogLine, | ||
| isValidStatus, | ||
| isValidTransition, | ||
| parseDiscoveredIssues, | ||
| splitFindings, | ||
| validateFindings, | ||
| validateTransition | ||
| }; |
+1
-1
| { | ||
| "name": "@papi-ai/shared", | ||
| "version": "0.1.7", | ||
| "version": "0.1.8", | ||
| "description": "Shared types and business rules for PAPI — used by both MCP server and dashboard", | ||
@@ -5,0 +5,0 @@ "license": "Elastic-2.0", |
34854
39.85%561
62.14%