@juzser/blacksmith 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { claimCoversPath } from './claims.js';
|
|
4
|
+
import { SmithError } from './errors.js';
|
|
5
|
+
import { appendEvent, readLineageEvents } from './events.js';
|
|
6
|
+
import { describeType } from './plan.js';
|
|
7
|
+
import { compileSchemas, validateRecord } from './schemas.js';
|
|
8
|
+
import { readAddedTasks } from './taskEvents.js';
|
|
9
|
+
import { bareTaskId, epicOfTaskId, taskIdsMatch } from './taskId.js';
|
|
10
|
+
import { loadTaxonomy, validateTag } from './taxonomy.js';
|
|
11
|
+
import { isWaived, WAIVABLE_SEVERITIES } from './waivers.js';
|
|
12
|
+
export class FindingError extends SmithError {
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Legal finding_status transitions (architecture §11, agent-constraints.md).
|
|
16
|
+
* `raised` and `confirmed` are the only non-terminal states that can end in
|
|
17
|
+
* `waived`/`expired` directly: S3/S4 findings never enter the bounce-to-coder
|
|
18
|
+
* fix cycle at all (severity.yml waiver_semantics), so a waiver or an
|
|
19
|
+
* epic-boundary expiry can land straight from either. Everything else is a
|
|
20
|
+
* strict forward chain; every other state is terminal (empty list).
|
|
21
|
+
*
|
|
22
|
+
* `amend-pending` hangs off the same two states and is gated a second time, on
|
|
23
|
+
* scope: only a spec-scoped finding can reach it (P9-9). It opens the sole
|
|
24
|
+
* exit for the S1/S2 spec finding of D-33, which is unwaivable by severity and
|
|
25
|
+
* unfixable by diff — the criterion it names moved in a new plan version.
|
|
26
|
+
*
|
|
27
|
+
* D-127: `amended` is reachable ONLY from `amend-pending`, and that is the
|
|
28
|
+
* point of the state existing. It used to hang off `raised`/`confirmed`
|
|
29
|
+
* directly, so cutting a plan version — or typing `finding transition --to
|
|
30
|
+
* amended` — discharged the one severity class severity.yml refuses to waive
|
|
31
|
+
* in a single call, referring to no diff, no task outcome and no grade. The
|
|
32
|
+
* amendment now names the task ids it made the discharge condition and the
|
|
33
|
+
* finding waits on them, exactly as `fix-pending` waits on a diff.
|
|
34
|
+
*
|
|
35
|
+
* `amend-pending → expired` is the way an unwaivable finding can still leave
|
|
36
|
+
* without its replacement work landing, and it is deliberate: it mirrors
|
|
37
|
+
* `fix-pending` and `fix-landed`, an epic-boundary expiry over real
|
|
38
|
+
* outstanding work. Narrower than what it replaces — an expiry is a recorded
|
|
39
|
+
* epic-boundary fact, not a status anyone can type at any moment — but it is
|
|
40
|
+
* not nothing, and this comment would be lying if it claimed otherwise.
|
|
41
|
+
*
|
|
42
|
+
* D-180: `waived` is the one closed state with a way back, because it is the
|
|
43
|
+
* one closed state a later event can contradict. isWaived() folds
|
|
44
|
+
* waiver-granted/waiver-denied last-decision-wins, so an operator who revokes
|
|
45
|
+
* a grant has already changed the answer — and while this row was empty, the
|
|
46
|
+
* finding stayed closed anyway, discretionary at the epic gate forever, with
|
|
47
|
+
* no verb that could reopen it. The edge goes back to where the waiver found
|
|
48
|
+
* it (`raised` or `confirmed`, whichever the grant recorded as from_status),
|
|
49
|
+
* and it is gated on `waiverRevokedBy` naming the denial: this is a
|
|
50
|
+
* reconciliation the log earns, not a status anyone can type.
|
|
51
|
+
*/
|
|
52
|
+
export const LEGAL_TRANSITIONS = Object.freeze({
|
|
53
|
+
raised: ['confirmed', 'refuted', 'waived', 'expired', 'amend-pending'],
|
|
54
|
+
confirmed: ['fix-pending', 'waived', 'expired', 'amend-pending'],
|
|
55
|
+
'fix-pending': ['fix-landed', 'expired'],
|
|
56
|
+
'fix-landed': ['fix-verified', 'expired'],
|
|
57
|
+
'amend-pending': ['amended', 'expired'],
|
|
58
|
+
refuted: [],
|
|
59
|
+
'fix-verified': [],
|
|
60
|
+
waived: ['raised', 'confirmed'],
|
|
61
|
+
expired: [],
|
|
62
|
+
amended: [],
|
|
63
|
+
});
|
|
64
|
+
/** The status an amendment puts a cited spec finding into; `amended` is what discharges it. */
|
|
65
|
+
export const AMEND_PENDING_STATUS = 'amend-pending';
|
|
66
|
+
/** Terminal: the amendment's task ids landed. Reachable only from AMEND_PENDING_STATUS. */
|
|
67
|
+
export const AMENDED_STATUS = 'amended';
|
|
68
|
+
/**
|
|
69
|
+
* Both edges of the amendment path. Scope is checked on each rather than on
|
|
70
|
+
* the entry edge alone: the table already makes `amended` unreachable except
|
|
71
|
+
* through `amend-pending`, so the second check is redundant *given the table*
|
|
72
|
+
* — which is exactly why it is here. It is the invariant, not a derivation of
|
|
73
|
+
* one, and it survives someone widening the table again (D-127).
|
|
74
|
+
*/
|
|
75
|
+
const AMENDMENT_STATUSES = [AMEND_PENDING_STATUS, AMENDED_STATUS];
|
|
76
|
+
/**
|
|
77
|
+
* A finding still awaiting something. Derived from LEGAL_TRANSITIONS above by
|
|
78
|
+
* reading, not by code: these are exactly the statuses with a non-empty
|
|
79
|
+
* outgoing edge set that are not themselves a decision. `waived` has outgoing
|
|
80
|
+
* edges too (a denial can reopen it) but is closed until one arrives, so it
|
|
81
|
+
* belongs with `refuted`/`expired`/`fix-verified`/`amended` on the other side.
|
|
82
|
+
*
|
|
83
|
+
* `amend-pending` is in it for D-127 Part B: that finding is open the same way
|
|
84
|
+
* `fix-pending` is, carrying an assigned discharge condition (amends_task_ids
|
|
85
|
+
* at amends_plan_version, not a diff) that has not yet been shown to hold. A
|
|
86
|
+
* reader holding the obligation data can tell a satisfied amendment from an
|
|
87
|
+
* unsatisfied one and epic.ts does; every reader that cannot treats the status
|
|
88
|
+
* as unconditionally open, which fails closed rather than silently agreeing
|
|
89
|
+
* the amendment landed.
|
|
90
|
+
*
|
|
91
|
+
* Lives here rather than in the modules that ask, because they are all asking
|
|
92
|
+
* the same lifecycle question about the same vocabulary: epic.ts's close gate
|
|
93
|
+
* ("what is still open against this epic"), db/queries.ts's kanban chip, and
|
|
94
|
+
* `smith crossfind run` ("which native findings is a second opinion allowed to
|
|
95
|
+
* corroborate"). A second copy would drift the moment taxonomy.yml grows a
|
|
96
|
+
* status.
|
|
97
|
+
*/
|
|
98
|
+
export const OPEN_FINDING_STATUSES = new Set([
|
|
99
|
+
'raised',
|
|
100
|
+
'confirmed',
|
|
101
|
+
'fix-pending',
|
|
102
|
+
'fix-landed',
|
|
103
|
+
AMEND_PENDING_STATUS,
|
|
104
|
+
]);
|
|
105
|
+
/**
|
|
106
|
+
* D-21 Part 4. Appended by `repairObligation` to correct a malformed
|
|
107
|
+
* `amends_task_ids` entry on a finding parked at `amend-pending` — never a
|
|
108
|
+
* `finding-transitioned` event, because a repair changes no status. Folded
|
|
109
|
+
* onto `amends_task_ids` the same way `finding-transitioned` is, last one in
|
|
110
|
+
* the log wins, mirroring `isWaived`'s waiver-granted/waiver-denied fold.
|
|
111
|
+
*/
|
|
112
|
+
export const FINDING_OBLIGATION_REPAIRED_EVENT_TYPE = 'finding-obligation-repaired';
|
|
113
|
+
/** The scope dimension's default: a record written before P9-9 carries none. */
|
|
114
|
+
export const DEFAULT_FINDING_SCOPE = 'diff';
|
|
115
|
+
/**
|
|
116
|
+
* Read a finding's scope, defaulting rather than requiring it. Every finding
|
|
117
|
+
* already in an event log predates the dimension, and rewriting history to
|
|
118
|
+
* add it is exactly the runtime taxonomy write the architecture forbids.
|
|
119
|
+
*/
|
|
120
|
+
export function findingScope(finding) {
|
|
121
|
+
return finding.finding_scope ?? DEFAULT_FINDING_SCOPE;
|
|
122
|
+
}
|
|
123
|
+
export const SPEC_FINDING_SCOPE = 'spec';
|
|
124
|
+
let cachedTaxonomy;
|
|
125
|
+
let cachedSchemas;
|
|
126
|
+
function resolveTaxonomyAndSchemas(opts) {
|
|
127
|
+
if (cachedTaxonomy === undefined)
|
|
128
|
+
cachedTaxonomy = loadTaxonomy();
|
|
129
|
+
if (cachedSchemas === undefined)
|
|
130
|
+
cachedSchemas = compileSchemas(cachedTaxonomy);
|
|
131
|
+
const taxonomy = opts.taxonomy ?? cachedTaxonomy;
|
|
132
|
+
const schemas = opts.schemas ?? cachedSchemas;
|
|
133
|
+
return { taxonomy, schemas };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The fingerprint's first component, and the only path form two findings may
|
|
137
|
+
* be compared on. Exported because crossFinding.ts co-locates an independent
|
|
138
|
+
* finder's evidence against native findings by path: `./src/a.ts`,
|
|
139
|
+
* `src/a.ts` and `src\\a.ts` are the same file, and a private normalizer
|
|
140
|
+
* would mean a second implementation that is allowed to disagree with the
|
|
141
|
+
* one the fingerprints were built with.
|
|
142
|
+
*/
|
|
143
|
+
export function normalizeFilePath(filePath) {
|
|
144
|
+
return path.posix.normalize(filePath.replace(/\\/g, '/')).replace(/^\.\//, '');
|
|
145
|
+
}
|
|
146
|
+
function escapeRegExp(text) {
|
|
147
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Strip a `<path>:<line>[:<col>]` reference anchored to THIS finding's own
|
|
151
|
+
* file path (full path and bare basename, since reviewers report both
|
|
152
|
+
* forms) — never a bare `:digits` anywhere in the text. A blanket
|
|
153
|
+
* `:\d+` strip is wrong: free-text like "debug endpoint exposed on :8080"
|
|
154
|
+
* would collide with an unrelated ":9090" finding (reproduced regression,
|
|
155
|
+
* reviewer finding #3).
|
|
156
|
+
*/
|
|
157
|
+
function stripPathLineRef(summary, filePath) {
|
|
158
|
+
const forwardSlashPath = filePath.replace(/\\/g, '/');
|
|
159
|
+
const candidates = new Set([filePath, forwardSlashPath, path.posix.basename(forwardSlashPath)]);
|
|
160
|
+
let result = summary;
|
|
161
|
+
for (const candidate of candidates) {
|
|
162
|
+
if (!candidate)
|
|
163
|
+
continue;
|
|
164
|
+
const pattern = new RegExp(`${escapeRegExp(candidate)}:\\d+(?::\\d+)?`, 'g');
|
|
165
|
+
result = result.replace(pattern, candidate);
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
/** lowercase, whitespace collapsed (spec wording; path-anchored line refs stripped by the caller). */
|
|
170
|
+
function normalizeSummary(summary) {
|
|
171
|
+
return summary
|
|
172
|
+
.toLowerCase()
|
|
173
|
+
.replace(/\bline\s+\d+\b/g, '')
|
|
174
|
+
.replace(/\s+/g, ' ')
|
|
175
|
+
.trim();
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Stable dedup key: same file + same finding_category + same normalized
|
|
179
|
+
* summary maps to the same fingerprint regardless of line-number drift
|
|
180
|
+
* between re-review rounds. Uses node:crypto (no new dependency).
|
|
181
|
+
*/
|
|
182
|
+
export function computeFingerprint(input) {
|
|
183
|
+
const summaryWithoutLineRef = stripPathLineRef(input.summary, input.filePath);
|
|
184
|
+
const material = [
|
|
185
|
+
normalizeFilePath(input.filePath),
|
|
186
|
+
input.category,
|
|
187
|
+
normalizeSummary(summaryWithoutLineRef),
|
|
188
|
+
].join(' ');
|
|
189
|
+
return createHash('sha256').update(material).digest('hex');
|
|
190
|
+
}
|
|
191
|
+
/** Fields the orchestrator owns; evidence carrying any of them is a contract breach, not a hint. */
|
|
192
|
+
const ORCHESTRATOR_OWNED_FINDING_FIELDS = [
|
|
193
|
+
'finding_id',
|
|
194
|
+
'task_id',
|
|
195
|
+
'fingerprint',
|
|
196
|
+
'finding_status',
|
|
197
|
+
'found_by',
|
|
198
|
+
'found_by_provider',
|
|
199
|
+
];
|
|
200
|
+
/**
|
|
201
|
+
* Readable and unique: the task scopes it (two tasks can legitimately hit the
|
|
202
|
+
* same file+category+summary), the fingerprint digest makes it deterministic,
|
|
203
|
+
* so re-minting the same evidence in a later round yields the same id.
|
|
204
|
+
*/
|
|
205
|
+
function mintFindingId(taskId, fingerprint) {
|
|
206
|
+
return `f-${taskId}-${fingerprint.slice(0, 8)}`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Guess which taxonomy value a judge meant when it wrote something close but
|
|
210
|
+
* not equal to one. Derived from the vocabulary itself rather than a synonym
|
|
211
|
+
* table, so it cannot drift from taxonomy.yml the way a hand-written map
|
|
212
|
+
* would, and it suggests without ever correcting — the judge's evidence is
|
|
213
|
+
* rejected either way, the suggestion just saves a round trip.
|
|
214
|
+
*
|
|
215
|
+
* Three rules, each returning only when it picks out exactly one candidate:
|
|
216
|
+
* the bare head (`S2` → `S2-major`), the bare tail (`coverage` →
|
|
217
|
+
* `test-coverage`, `design` → `visual-design`), and one shared hyphen token
|
|
218
|
+
* (`test-gap` → `test-coverage` — the wave-3 slip in D-37 that cost a full
|
|
219
|
+
* gate round).
|
|
220
|
+
*/
|
|
221
|
+
function suggestCanonical(values, written) {
|
|
222
|
+
const head = values.filter((value) => value.startsWith(`${written}-`));
|
|
223
|
+
if (head.length === 1)
|
|
224
|
+
return head[0];
|
|
225
|
+
const tail = values.filter((value) => value.endsWith(`-${written}`));
|
|
226
|
+
if (tail.length === 1)
|
|
227
|
+
return tail[0];
|
|
228
|
+
const tokens = new Set(written.split('-').filter((token) => token !== ''));
|
|
229
|
+
const shared = values.filter((value) => value.split('-').some((token) => tokens.has(token)));
|
|
230
|
+
if (shared.length === 1)
|
|
231
|
+
return shared[0];
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Turn a judge's evidence array into `raiseFinding` inputs, minting the
|
|
236
|
+
* identity fields the orchestrator owns (interview N-2).
|
|
237
|
+
*
|
|
238
|
+
* Three failures are caught here rather than at the schema, because the
|
|
239
|
+
* schema's message does not tell an operator which side broke the contract:
|
|
240
|
+
* evidence that carries orchestrator-owned identity (the judge overstepped),
|
|
241
|
+
* a severity written as the bare grade "S2" instead of the canonical taxonomy
|
|
242
|
+
* value "S2-major" — by far the most common template-to-taxonomy slip — and
|
|
243
|
+
* (P9-20 / D-37) a `finding_category` outside the vocabulary. The category
|
|
244
|
+
* used to be copied through untouched and first rejected at gate intake,
|
|
245
|
+
* five checks later, by an error naming no legal value; a reviewer that wrote
|
|
246
|
+
* `test-gap` for `test-coverage` cost a whole gate round that way.
|
|
247
|
+
*/
|
|
248
|
+
export function mintFindings(evidence, ctx, opts = {}) {
|
|
249
|
+
const { taxonomy } = resolveTaxonomyAndSchemas(opts);
|
|
250
|
+
const severities = taxonomy.dimensions.severity ?? [];
|
|
251
|
+
const categories = taxonomy.dimensions.finding_category ?? [];
|
|
252
|
+
return evidence.map((item, index) => {
|
|
253
|
+
const overstep = ORCHESTRATOR_OWNED_FINDING_FIELDS.filter((field) => item[field] !== undefined);
|
|
254
|
+
if (overstep.length > 0) {
|
|
255
|
+
throw new FindingError('findings.evidence-carries-identity', `Finding evidence at index ${index} set orchestrator-owned field(s) ${overstep.join(', ')}. Judges return evidence only.`, { index, fields: [...overstep] });
|
|
256
|
+
}
|
|
257
|
+
if (!severities.includes(item.severity)) {
|
|
258
|
+
const canonical = suggestCanonical(severities, item.severity);
|
|
259
|
+
throw new FindingError('findings.non-canonical-severity', canonical
|
|
260
|
+
? `Finding evidence at index ${index} used severity "${item.severity}"; the taxonomy value is "${canonical}" — write it out in full.`
|
|
261
|
+
: `Finding evidence at index ${index} used unknown severity "${item.severity}". Valid: ${severities.join(', ')}.`, { index, severity: item.severity });
|
|
262
|
+
}
|
|
263
|
+
if (!categories.includes(item.finding_category)) {
|
|
264
|
+
const canonical = suggestCanonical(categories, item.finding_category);
|
|
265
|
+
throw new FindingError('findings.non-canonical-finding-category', `Finding evidence at index ${index} used ` +
|
|
266
|
+
(canonical
|
|
267
|
+
? `finding_category "${item.finding_category}"; the taxonomy value is "${canonical}" — write it out in full. `
|
|
268
|
+
: `unknown finding_category "${item.finding_category}". `) +
|
|
269
|
+
`Valid: ${categories.join(', ')}.`, { index, finding_category: item.finding_category });
|
|
270
|
+
}
|
|
271
|
+
if (ctx.spec && !item.criterion_ref) {
|
|
272
|
+
throw new FindingError('findings.spec-evidence-needs-criterion', `Spec-review evidence at index ${index} names no criterion_ref. "The plan is wrong" has to say which clause.`, { index });
|
|
273
|
+
}
|
|
274
|
+
const fingerprint = computeFingerprint({
|
|
275
|
+
filePath: item.file_path,
|
|
276
|
+
category: item.finding_category,
|
|
277
|
+
summary: item.summary,
|
|
278
|
+
});
|
|
279
|
+
const epicId = epicOfTaskId(ctx.taskId);
|
|
280
|
+
const finding = {
|
|
281
|
+
finding_id: mintFindingId(ctx.taskId, fingerprint),
|
|
282
|
+
task_id: ctx.taskId,
|
|
283
|
+
// Carried, not derived downstream (D-49/P9-10). Omitted rather than
|
|
284
|
+
// guessed when the task id names no epic: a wrong epic reads exactly
|
|
285
|
+
// like a right one everywhere it is consumed.
|
|
286
|
+
...(epicId === null ? {} : { epic_id: epicId }),
|
|
287
|
+
finding_category: item.finding_category,
|
|
288
|
+
severity: item.severity,
|
|
289
|
+
// raiseFinding forces this too; set here so the minted draft is already
|
|
290
|
+
// a valid finding record on its own (e.g. when written to disk first).
|
|
291
|
+
finding_status: 'raised',
|
|
292
|
+
// A criterion_ref on a diff dispatch is dropped, not promoted: scope
|
|
293
|
+
// comes from how the judge was dispatched, never from what it returned.
|
|
294
|
+
...(ctx.spec && item.criterion_ref
|
|
295
|
+
? {
|
|
296
|
+
finding_scope: SPEC_FINDING_SCOPE,
|
|
297
|
+
spec_ref: { plan_version: ctx.spec.planVersion, criterion_ref: item.criterion_ref },
|
|
298
|
+
}
|
|
299
|
+
: {}),
|
|
300
|
+
summary: item.summary,
|
|
301
|
+
failure_scenario: item.failure_scenario,
|
|
302
|
+
found_by: ctx.foundBy,
|
|
303
|
+
...(ctx.foundByProvider ? { found_by_provider: ctx.foundByProvider } : {}),
|
|
304
|
+
};
|
|
305
|
+
return { finding, filePath: item.file_path };
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Move a minted finding to the task that actually owns its file (D-41/P9-24).
|
|
310
|
+
*
|
|
311
|
+
* The id is re-minted, not patched: `finding_id` embeds the task id, so a
|
|
312
|
+
* finding carrying `f-<gated-task>-<fp>` while claiming `task_id: <owner>`
|
|
313
|
+
* would read as two different answers to "whose is this" in the one record
|
|
314
|
+
* an operator greps. The fingerprint is unchanged by construction — it is
|
|
315
|
+
* computed from file, category and summary, none of which ownership touches —
|
|
316
|
+
* so dedup and waivers survive the move.
|
|
317
|
+
*/
|
|
318
|
+
export function reattributeFinding(input, taskId) {
|
|
319
|
+
const fingerprint = computeFingerprint({
|
|
320
|
+
filePath: input.filePath,
|
|
321
|
+
category: input.finding.finding_category,
|
|
322
|
+
summary: input.finding.summary,
|
|
323
|
+
});
|
|
324
|
+
const epicId = epicOfTaskId(taskId);
|
|
325
|
+
// `epic_id` moves with `task_id` — a finding reassigned across epics whose
|
|
326
|
+
// epic field still named the old one would be counted twice by the epic
|
|
327
|
+
// verdict, once correctly and once as a phantom open finding.
|
|
328
|
+
const { epic_id: _dropped, ...rest } = input.finding;
|
|
329
|
+
return {
|
|
330
|
+
...input,
|
|
331
|
+
finding: {
|
|
332
|
+
...rest,
|
|
333
|
+
task_id: taskId,
|
|
334
|
+
...(epicId === null ? {} : { epic_id: epicId }),
|
|
335
|
+
finding_id: mintFindingId(taskId, fingerprint),
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Validate the finding (schema + taxonomy, same pattern as plan.ts), compute
|
|
341
|
+
* its fingerprint, and append it to the event log — unless that fingerprint
|
|
342
|
+
* is already waived AND this finding sits at a waivable severity, in which
|
|
343
|
+
* case a finding-suppressed event is logged instead (analytics keep seeing
|
|
344
|
+
* the recurrence) and no duplicate raised finding is appended. An S1/S2 is
|
|
345
|
+
* raised even over a waived fingerprint (D-196): a waiver answers the
|
|
346
|
+
* question it was asked, and severity.yml never lets that question be asked
|
|
347
|
+
* about a merge-blocking finding. Always forces finding_status to "raised": the whole
|
|
348
|
+
* point of this call is the raise transition, regardless of what the
|
|
349
|
+
* reviewer/verifier JSON happened to set.
|
|
350
|
+
*/
|
|
351
|
+
export async function raiseFinding(input, ctx, opts = {}) {
|
|
352
|
+
const fingerprint = computeFingerprint({
|
|
353
|
+
filePath: input.filePath,
|
|
354
|
+
category: input.finding.finding_category,
|
|
355
|
+
summary: input.finding.summary,
|
|
356
|
+
});
|
|
357
|
+
// Backfill `epic_id` for producers that never set it (`smith findings raise`,
|
|
358
|
+
// a hand-written draft): this is the one boundary every finding crosses, so
|
|
359
|
+
// one derivation here means every stored record carries the field. Still
|
|
360
|
+
// omitted when the task id names no epic — see the field's comment.
|
|
361
|
+
const epicId = input.finding.epic_id ?? epicOfTaskId(input.finding.task_id) ?? undefined;
|
|
362
|
+
// `file_path` is stamped in the same normalized form the fingerprint was
|
|
363
|
+
// computed from, so "same file" means the same thing to the dedup key and to
|
|
364
|
+
// the claims join.
|
|
365
|
+
const finding = {
|
|
366
|
+
...input.finding,
|
|
367
|
+
...(epicId === undefined ? {} : { epic_id: epicId }),
|
|
368
|
+
finding_status: 'raised',
|
|
369
|
+
fingerprint,
|
|
370
|
+
file_path: normalizeFilePath(input.filePath),
|
|
371
|
+
};
|
|
372
|
+
// Scope and ref travel together or not at all. Enforced here rather than in
|
|
373
|
+
// finding.schema.json because the JSON-Schema form of "required when scope
|
|
374
|
+
// is spec" needs `const: "spec"` inline, and inlining a taxonomy value in a
|
|
375
|
+
// schema is the drift the schema's own $comment forbids (architecture §8).
|
|
376
|
+
if (findingScope(finding) === SPEC_FINDING_SCOPE && finding.spec_ref === undefined) {
|
|
377
|
+
throw new FindingError('findings.spec-finding-needs-ref', `Finding "${finding.finding_id}" is spec-scoped but names no criterion. A spec finding blocks the plan, so it has to say which clause moved.`, { findingId: finding.finding_id });
|
|
378
|
+
}
|
|
379
|
+
if (findingScope(finding) !== SPEC_FINDING_SCOPE && finding.spec_ref !== undefined) {
|
|
380
|
+
throw new FindingError('findings.spec-ref-without-scope', `Finding "${finding.finding_id}" carries a spec_ref but is scoped "${findingScope(finding)}". A diff finding is answered by a diff, not by a plan amendment.`, { findingId: finding.finding_id, scope: findingScope(finding) });
|
|
381
|
+
}
|
|
382
|
+
const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
|
|
383
|
+
const validation = validateRecord(schemas, taxonomy, 'finding', finding);
|
|
384
|
+
if (!validation.valid) {
|
|
385
|
+
throw new FindingError('findings.invalid-record', 'Finding failed schema/taxonomy validation.', { errors: validation.errors });
|
|
386
|
+
}
|
|
387
|
+
// D-196: the waiver has to be one that could have been granted over THIS
|
|
388
|
+
// finding. The fingerprint is file + category + summary and deliberately
|
|
389
|
+
// blind to severity, so that line-number and wording drift between
|
|
390
|
+
// re-review rounds still dedups (see computeFingerprint) — which means a
|
|
391
|
+
// grant made over an S3 is keyed the same as an S1 raised on the identical
|
|
392
|
+
// sentence, and the S1 vanished into a finding-suppressed event that
|
|
393
|
+
// nothing blocks on. Every other door into "a waiver answers this finding"
|
|
394
|
+
// asks about severity first: pendingBatch only offers S3/S4, applyBatch
|
|
395
|
+
// refuses to grant over a non-waivable sibling, reconcileFindingsToWaived
|
|
396
|
+
// skips them, and transition() refuses `-> waived` outright. This is the
|
|
397
|
+
// fifth door, and suppression is the only one of the five that leaves no
|
|
398
|
+
// finding behind at all.
|
|
399
|
+
const suppressible = WAIVABLE_SEVERITIES.includes(finding.severity);
|
|
400
|
+
const waived = suppressible && (await isWaived(fingerprint, { sessionId: ctx.sessionId }, opts));
|
|
401
|
+
if (waived) {
|
|
402
|
+
await appendEvent({
|
|
403
|
+
session_id: ctx.sessionId,
|
|
404
|
+
actor: ctx.actor ?? finding.found_by,
|
|
405
|
+
event_type: 'finding-suppressed',
|
|
406
|
+
task_id: finding.task_id,
|
|
407
|
+
plan_version: ctx.planVersion,
|
|
408
|
+
causal_parent: ctx.causalParent,
|
|
409
|
+
payload: { fingerprint, finding_id: finding.finding_id, task_id: finding.task_id },
|
|
410
|
+
}, opts);
|
|
411
|
+
return { suppressed: true, fingerprint, taskId: finding.task_id };
|
|
412
|
+
}
|
|
413
|
+
await appendEvent({
|
|
414
|
+
session_id: ctx.sessionId,
|
|
415
|
+
actor: ctx.actor ?? finding.found_by,
|
|
416
|
+
event_type: 'finding-raised',
|
|
417
|
+
task_id: finding.task_id,
|
|
418
|
+
plan_version: ctx.planVersion,
|
|
419
|
+
causal_parent: ctx.causalParent,
|
|
420
|
+
payload: finding,
|
|
421
|
+
}, opts);
|
|
422
|
+
return { suppressed: false, finding };
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Current state of every finding in this session's LINEAGE: a fold, never a
|
|
426
|
+
* mutable store.
|
|
427
|
+
*
|
|
428
|
+
* Lineage, not session, since D-119. An epic that continues in a fresh session
|
|
429
|
+
* — the thing `validateCausalParent` documents as "the documented way to run a
|
|
430
|
+
* large epic" — used to arrive at its second session with an empty board, and
|
|
431
|
+
* every gate downstream of this call answered `go` on it in the same words it
|
|
432
|
+
* would have used for a genuinely clean one.
|
|
433
|
+
*/
|
|
434
|
+
export async function listFindings(sessionId, filter = {}, opts = {}) {
|
|
435
|
+
return foldFindings(await readLineageEvents(sessionId, opts), filter);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The same fold, over events the caller already holds.
|
|
439
|
+
*
|
|
440
|
+
* `db/projector.ts` is why this is separate. `projectFindings` re-folds ONE
|
|
441
|
+
* session's log and replaces only that session's rows, "leaving every other
|
|
442
|
+
* session's projection intact" (`apply()`'s own contract) — so it must keep
|
|
443
|
+
* reading one session, or a continuation's rows would become a second copy of
|
|
444
|
+
* every ancestor's findings under a new session_id. Handing it a narrower
|
|
445
|
+
* reader by NAME is the point: the safe scope is what `listFindings` does by
|
|
446
|
+
* default, and the narrow one has to be asked for.
|
|
447
|
+
*/
|
|
448
|
+
export function foldFindings(events, filter = {}) {
|
|
449
|
+
return foldFindingsDetailed(events, filter).findings;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The fields the fold itself dereferences. Deliberately NOT the full
|
|
453
|
+
* `finding.schema.json` required list: this is a reader coping with history,
|
|
454
|
+
* and rejecting an old record for a field no reader touches would quarantine
|
|
455
|
+
* data that is in fact usable. `appendEvent` enforces the full schema on
|
|
456
|
+
* everything written from now on.
|
|
457
|
+
*/
|
|
458
|
+
export const REQUIRED_FOLD_FIELDS = ['finding_id', 'task_id'];
|
|
459
|
+
/**
|
|
460
|
+
* The fields the SQLite *projection* needs on top of the fold's — together,
|
|
461
|
+
* every payload-sourced `notNull()` column of `db/schema.ts`'s `findings`
|
|
462
|
+
* table. Superset of `REQUIRED_FOLD_FIELDS` by construction, and
|
|
463
|
+
* `test/db/projector.test.ts` asserts the other direction: no `notNull()`
|
|
464
|
+
* column may be added to that table without a payload field here to fill it.
|
|
465
|
+
*
|
|
466
|
+
* D-141. These two lists are allowed to differ — the fold serves
|
|
467
|
+
* `smith findings list`, which can show a record it only partly understands,
|
|
468
|
+
* while an INSERT either satisfies every constraint or aborts the transaction —
|
|
469
|
+
* but they must differ *knowingly*. They did not: 18 of this repo's 57
|
|
470
|
+
* `finding-raised` records predate `fingerprint`, the fold returned all of
|
|
471
|
+
* them, and `smith db rebuild` died on the first with
|
|
472
|
+
* `NOT NULL constraint failed: findings.fingerprint`. Splitting the lists
|
|
473
|
+
* explicitly lets the projector quarantine what it cannot store while the fold
|
|
474
|
+
* keeps showing it.
|
|
475
|
+
*/
|
|
476
|
+
export const REQUIRED_PROJECTION_FIELDS = [
|
|
477
|
+
...REQUIRED_FOLD_FIELDS,
|
|
478
|
+
'fingerprint',
|
|
479
|
+
'finding_category',
|
|
480
|
+
'severity',
|
|
481
|
+
'finding_status',
|
|
482
|
+
'summary',
|
|
483
|
+
'found_by',
|
|
484
|
+
];
|
|
485
|
+
/**
|
|
486
|
+
* Names the projection-required fields this finding cannot fill, in declaration
|
|
487
|
+
* order. Empty means the record can become a row.
|
|
488
|
+
*/
|
|
489
|
+
export function missingProjectionFields(finding) {
|
|
490
|
+
const record = finding;
|
|
491
|
+
return REQUIRED_PROJECTION_FIELDS.filter((field) => typeof record[field] !== 'string');
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* `foldFindings`, plus what it had to leave behind.
|
|
495
|
+
*
|
|
496
|
+
* D-135. The fold rebuilds each Finding from `record.payload` alone, so a
|
|
497
|
+
* payload missing `task_id` folded to `task_id: undefined` and the `--epic`
|
|
498
|
+
* filter threw a bare TypeError out of `smith findings list`. `appendEvent`
|
|
499
|
+
* now rejects such a payload at write time, but the log is append-only:
|
|
500
|
+
* records written before that guard existed are still on disk and cannot be
|
|
501
|
+
* validated away retroactively, so the reader has to cope with them.
|
|
502
|
+
*
|
|
503
|
+
* Coping is not the same as ignoring. Dropping a malformed record silently
|
|
504
|
+
* would trade a loud crash for a quiet undercount — the same failure shape as
|
|
505
|
+
* D-130, where a well-formed short answer read exactly like a complete one.
|
|
506
|
+
* So the fold quarantines the record and NAMES it, and callers that report to
|
|
507
|
+
* a human surface the quarantine alongside the count.
|
|
508
|
+
*/
|
|
509
|
+
export function foldFindingsDetailed(events, filter = {}) {
|
|
510
|
+
const byId = new Map();
|
|
511
|
+
const skipped = [];
|
|
512
|
+
// Which record raised each finding, so a quarantine entry can name the row
|
|
513
|
+
// an operator has to go repair (D-143).
|
|
514
|
+
const eventIdOf = new Map();
|
|
515
|
+
for (const { event_id, record } of events) {
|
|
516
|
+
if (record.event_type === 'finding-raised') {
|
|
517
|
+
const finding = record.payload;
|
|
518
|
+
const missing = REQUIRED_FOLD_FIELDS.filter((field) => typeof finding?.[field] !== 'string');
|
|
519
|
+
if (missing.length > 0) {
|
|
520
|
+
skipped.push({
|
|
521
|
+
event_id,
|
|
522
|
+
...(typeof finding?.finding_id === 'string' ? { finding_id: finding.finding_id } : {}),
|
|
523
|
+
reason: `finding-raised payload is missing required string field(s): ${missing.join(', ')}`,
|
|
524
|
+
});
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
byId.set(finding.finding_id, { ...finding });
|
|
528
|
+
eventIdOf.set(finding.finding_id, event_id);
|
|
529
|
+
}
|
|
530
|
+
else if (record.event_type === 'finding-transitioned') {
|
|
531
|
+
const payload = record.payload;
|
|
532
|
+
const existing = byId.get(payload.finding_id);
|
|
533
|
+
if (existing) {
|
|
534
|
+
// D-180: the revocation takes the waiver's id with it, so a reopened
|
|
535
|
+
// finding does not still name the waiver that stopped holding it.
|
|
536
|
+
if (payload.waiver_revoked_by)
|
|
537
|
+
delete existing.waiver_id;
|
|
538
|
+
byId.set(payload.finding_id, {
|
|
539
|
+
...existing,
|
|
540
|
+
finding_status: payload.to_status,
|
|
541
|
+
...(payload.waiver_id ? { waiver_id: payload.waiver_id } : {}),
|
|
542
|
+
...(payload.amends_task_ids ? { amends_task_ids: payload.amends_task_ids } : {}),
|
|
543
|
+
...(payload.amends_plan_version
|
|
544
|
+
? { amends_plan_version: payload.amends_plan_version }
|
|
545
|
+
: {}),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
else if (record.event_type === FINDING_OBLIGATION_REPAIRED_EVENT_TYPE) {
|
|
550
|
+
// D-21 Part 4: last-decision-wins onto amends_task_ids, mirroring
|
|
551
|
+
// isWaived's waiver-granted/waiver-denied fold -- a repair changes no
|
|
552
|
+
// status, so it is not a finding-transitioned event and would
|
|
553
|
+
// otherwise never reach this fold at all.
|
|
554
|
+
const payload = record.payload;
|
|
555
|
+
const existing = byId.get(payload.finding_id);
|
|
556
|
+
if (existing) {
|
|
557
|
+
byId.set(payload.finding_id, {
|
|
558
|
+
...existing,
|
|
559
|
+
...(payload.to_obligation ? { amends_task_ids: [...payload.to_obligation] } : {}),
|
|
560
|
+
...(payload.reason !== undefined ? { obligation_repair_reason: payload.reason } : {}),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
let results = [...byId.values()];
|
|
566
|
+
// D-143: the same two-spelling rule D-130 gave `filterEvents`. A raw `===`
|
|
567
|
+
// answered `findings list --task <epic>/<task>` with an empty list for a
|
|
568
|
+
// task holding two findings raised under the bare spelling.
|
|
569
|
+
if (filter.taskId !== undefined)
|
|
570
|
+
results = results.filter((f) => taskIdsMatch(f.task_id, filter.taskId));
|
|
571
|
+
if (filter.epic !== undefined) {
|
|
572
|
+
// The filter is right not to guess (see FindingFilter.epic): a bare id is
|
|
573
|
+
// not evidence that the finding belongs to an epic of that name. But this
|
|
574
|
+
// fold feeds the epic close gate, and there "matches nothing" was read as
|
|
575
|
+
// "this epic has no findings" — `envkit-config-loader` folded 0 findings
|
|
576
|
+
// and 0 quarantine while three `raised` ones sat in its log. So a record
|
|
577
|
+
// this filter cannot PLACE is quarantined rather than dropped: unknown
|
|
578
|
+
// membership is not evidence of non-membership, the same fail-closed call
|
|
579
|
+
// as an unreadable payload above (D-143).
|
|
580
|
+
const wanted = filter.epic;
|
|
581
|
+
const placed = [];
|
|
582
|
+
for (const finding of results) {
|
|
583
|
+
const epicId = finding.epic_id ?? epicOfTaskId(finding.task_id);
|
|
584
|
+
if (epicId === wanted) {
|
|
585
|
+
placed.push(finding);
|
|
586
|
+
}
|
|
587
|
+
else if (epicId === null) {
|
|
588
|
+
skipped.push({
|
|
589
|
+
event_id: eventIdOf.get(finding.finding_id) ?? '(unknown)',
|
|
590
|
+
finding_id: finding.finding_id,
|
|
591
|
+
reason: `finding names no epic: task_id "${finding.task_id}" is unqualified and the record carries no epic_id, so it cannot be shown to belong to "${wanted}" or to any other epic`,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
results = placed;
|
|
596
|
+
}
|
|
597
|
+
if (filter.status !== undefined)
|
|
598
|
+
results = results.filter((f) => f.finding_status === filter.status);
|
|
599
|
+
if (filter.severity !== undefined)
|
|
600
|
+
results = results.filter((f) => f.severity === filter.severity);
|
|
601
|
+
if (filter.category !== undefined)
|
|
602
|
+
results = results.filter((f) => f.finding_category === filter.category);
|
|
603
|
+
return { findings: results, skipped };
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* The status a finding held immediately before its waiver closed it, read off
|
|
607
|
+
* the grant's own `from_status` (D-180). `undefined` when the finding is not
|
|
608
|
+
* waived, is unknown, or was waived by a transition written before this field
|
|
609
|
+
* existed — callers reopening a waiver must treat that as "cannot place it"
|
|
610
|
+
* rather than picking a status.
|
|
611
|
+
*
|
|
612
|
+
* Exported for waivers.ts, which needs the target BEFORE it calls transition()
|
|
613
|
+
* and cannot get it from listFindings: the fold keeps the current status, and
|
|
614
|
+
* the one this asks for is precisely the one the fold overwrote.
|
|
615
|
+
*/
|
|
616
|
+
export async function preWaiverStatus(findingId, sessionId, opts = {}) {
|
|
617
|
+
const events = await readLineageEvents(sessionId, opts);
|
|
618
|
+
let from;
|
|
619
|
+
for (const { record } of events) {
|
|
620
|
+
if (record.event_type !== 'finding-transitioned')
|
|
621
|
+
continue;
|
|
622
|
+
const payload = record.payload;
|
|
623
|
+
if (payload.finding_id !== findingId)
|
|
624
|
+
continue;
|
|
625
|
+
if (payload.to_status === 'waived')
|
|
626
|
+
from = payload.from_status;
|
|
627
|
+
}
|
|
628
|
+
return from;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Which of `finding`'s obligations the offered evidence does NOT discharge.
|
|
632
|
+
*
|
|
633
|
+
* An obligation is discharged by a row naming the same task (bare compare,
|
|
634
|
+
* D-46/P9-29: `amends_task_ids` comes off the plan and the evidence off the
|
|
635
|
+
* fold, and the two registers spell ids either way) at a plan version at or
|
|
636
|
+
* past the one the amendment cut. The version clause is what stops a task that
|
|
637
|
+
* landed under the plan the amendment REPLACED from discharging it — the D-125
|
|
638
|
+
* shape, where a superseded task would satisfy the amendment that superseded it.
|
|
639
|
+
*
|
|
640
|
+
* An amendment naming no ids, or carrying no version, has no discharge
|
|
641
|
+
* condition and so can never be satisfied: every id is outstanding and there
|
|
642
|
+
* are none, which reads as `null` rather than `[]`. summarizeEpic draws the
|
|
643
|
+
* same distinction, and for the same reason — "nothing to wait on" and
|
|
644
|
+
* "waiting on nothing outstanding" must not look alike.
|
|
645
|
+
*/
|
|
646
|
+
export function outstandingObligations(finding, evidence) {
|
|
647
|
+
const obligations = finding.amends_task_ids ?? [];
|
|
648
|
+
const version = finding.amends_plan_version;
|
|
649
|
+
if (obligations.length === 0 || version === undefined)
|
|
650
|
+
return null;
|
|
651
|
+
return obligations.filter((id) => {
|
|
652
|
+
const bare = bareTaskId(id);
|
|
653
|
+
return !evidence.some((row) => bareTaskId(row.taskId) === bare && row.planVersion >= version);
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Enforce the finding_status state machine (LEGAL_TRANSITIONS) and append a
|
|
658
|
+
* finding-transitioned event. Folds the log itself (rather than delegating
|
|
659
|
+
* to listFindings) so it has the one finding's raw history without paying
|
|
660
|
+
* for a full-session scan-and-filter.
|
|
661
|
+
*
|
|
662
|
+
* `→ waived` is additionally gated on WAIVABLE_SEVERITIES (severity.yml
|
|
663
|
+
* waiver_semantics: only S3/S4 findings are ever waived) — the same
|
|
664
|
+
* constant waivers.ts uses, so this is enforced identically whether reached
|
|
665
|
+
* via a direct transition() call (e.g. the CLI) or via a waiver grant
|
|
666
|
+
* (waivers.ts's reconciliation).
|
|
667
|
+
*
|
|
668
|
+
* Both amendment edges are gated the same way on scope: they are the spec
|
|
669
|
+
* finding's exit, and letting a diff finding take them would turn "the plan
|
|
670
|
+
* changed" into a way to close code defects nobody fixed (P9-9). `→
|
|
671
|
+
* amend-pending` is additionally gated on naming at least one task id, since
|
|
672
|
+
* an amendment owing nothing discharges the finding it cites on the spot
|
|
673
|
+
* (D-127).
|
|
674
|
+
*
|
|
675
|
+
* `→ amended` is gated on the caller SHOWING the obligation discharged —
|
|
676
|
+
* `amendsSatisfiedBy`, checked against the finding's own amends_task_ids and
|
|
677
|
+
* amends_plan_version. The gate lives here for the reason the waiver gate does:
|
|
678
|
+
* closeEpic is not the only caller, and while the satisfaction check lived only
|
|
679
|
+
* there, `smith findings transition <id> amended` closed the unwaivable class
|
|
680
|
+
* with nothing built — D-127 through the operator's door. Evidence is supplied
|
|
681
|
+
* rather than folded here so transition() keeps costing one finding's history
|
|
682
|
+
* instead of the whole task log; a caller that fabricates it is forging a
|
|
683
|
+
* record, which is a different problem from an open door.
|
|
684
|
+
*/
|
|
685
|
+
export async function transition(findingId, newStatus, ctx, opts = {}, extra = {}) {
|
|
686
|
+
// Lineage (D-119): a finding raised in the first session of a cross-session
|
|
687
|
+
// epic is otherwise not FOUND from the second, and this function's own
|
|
688
|
+
// "unknown finding" error would be the answer to a finding that plainly
|
|
689
|
+
// exists.
|
|
690
|
+
const events = await readLineageEvents(ctx.sessionId, opts);
|
|
691
|
+
let current;
|
|
692
|
+
/**
|
|
693
|
+
* The status the waiver closed over, from the grant's own from_status
|
|
694
|
+
* (D-180). Read off the log rather than assumed: a finding waived out of
|
|
695
|
+
* `raised` was never confirmed, and sending it back to `confirmed` would
|
|
696
|
+
* credit it with a verification that never happened.
|
|
697
|
+
*/
|
|
698
|
+
let preWaiverStatus;
|
|
699
|
+
for (const { record } of events) {
|
|
700
|
+
if (record.event_type === 'finding-raised') {
|
|
701
|
+
const finding = record.payload;
|
|
702
|
+
if (finding.finding_id === findingId)
|
|
703
|
+
current = { ...finding };
|
|
704
|
+
}
|
|
705
|
+
else if (record.event_type === 'finding-transitioned') {
|
|
706
|
+
const payload = record.payload;
|
|
707
|
+
if (payload.finding_id === findingId && current) {
|
|
708
|
+
if (payload.to_status === 'waived')
|
|
709
|
+
preWaiverStatus = payload.from_status;
|
|
710
|
+
if (payload.waiver_revoked_by)
|
|
711
|
+
delete current.waiver_id;
|
|
712
|
+
current = {
|
|
713
|
+
...current,
|
|
714
|
+
finding_status: payload.to_status,
|
|
715
|
+
...(payload.waiver_id ? { waiver_id: payload.waiver_id } : {}),
|
|
716
|
+
...(payload.amends_task_ids ? { amends_task_ids: payload.amends_task_ids } : {}),
|
|
717
|
+
...(payload.amends_plan_version
|
|
718
|
+
? { amends_plan_version: payload.amends_plan_version }
|
|
719
|
+
: {}),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
else if (record.event_type === FINDING_OBLIGATION_REPAIRED_EVENT_TYPE) {
|
|
724
|
+
// D-21 Part 4: a repair changes amends_task_ids without touching
|
|
725
|
+
// finding_status, so it is folded here too -- otherwise a -> amended
|
|
726
|
+
// discharge (outstandingObligations, just below) would keep judging the
|
|
727
|
+
// ORIGINAL, possibly-malformed obligation rather than the repaired one.
|
|
728
|
+
const payload = record.payload;
|
|
729
|
+
if (payload.finding_id === findingId && current) {
|
|
730
|
+
current = {
|
|
731
|
+
...current,
|
|
732
|
+
...(payload.to_obligation ? { amends_task_ids: [...payload.to_obligation] } : {}),
|
|
733
|
+
...(payload.reason !== undefined ? { obligation_repair_reason: payload.reason } : {}),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (!current) {
|
|
739
|
+
throw new FindingError('findings.unknown-finding', `No raised finding with id "${findingId}".`, {
|
|
740
|
+
findingId,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
const legal = LEGAL_TRANSITIONS[current.finding_status] ?? [];
|
|
744
|
+
if (!legal.includes(newStatus)) {
|
|
745
|
+
throw new FindingError('findings.illegal-transition', `Cannot transition finding "${findingId}" from "${current.finding_status}" to "${newStatus}".`, { findingId, from: current.finding_status, to: newStatus });
|
|
746
|
+
}
|
|
747
|
+
// D-180: the only way out of `waived` is a revocation the log can be pointed
|
|
748
|
+
// at. Checked before the target is compared so "you named no denial" is the
|
|
749
|
+
// answer to a caller who named none, rather than a status complaint.
|
|
750
|
+
if (current.finding_status === 'waived') {
|
|
751
|
+
if (extra.waiverRevokedBy === undefined) {
|
|
752
|
+
throw new FindingError('findings.waiver-revocation-unproven', `Finding "${findingId}" is waived, and a waiver is undone by revoking it, not by typing a status at it. Record the operator's reversal first — "smith waiver apply" with a "denied" decision on this fingerprint reopens the finding as part of writing the denial.`, { findingId, to: newStatus });
|
|
753
|
+
}
|
|
754
|
+
if (newStatus !== preWaiverStatus) {
|
|
755
|
+
throw new FindingError('findings.waiver-revocation-wrong-status', `Finding "${findingId}" was waived out of "${preWaiverStatus ?? 'an unrecorded status'}", so revoking that waiver returns it there, not to "${newStatus}". A revocation restores what the waiver closed over; it does not re-grade the finding.`, { findingId, to: newStatus, preWaiverStatus: preWaiverStatus ?? null });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (newStatus === 'waived' && !WAIVABLE_SEVERITIES.includes(current.severity)) {
|
|
759
|
+
throw new FindingError('findings.not-waivable', `Finding "${findingId}" has severity "${current.severity}"; only ${WAIVABLE_SEVERITIES.join('/')} findings are waivable.`, { findingId, severity: current.severity });
|
|
760
|
+
}
|
|
761
|
+
if (AMENDMENT_STATUSES.includes(newStatus) && findingScope(current) !== SPEC_FINDING_SCOPE) {
|
|
762
|
+
throw new FindingError('findings.not-amendable', `Finding "${findingId}" is scoped "${findingScope(current)}", so it cannot take the amendment path at "${newStatus}": only a spec finding closes by amending the plan. A defect in the diff is closed by fixing the diff.`, { findingId, scope: findingScope(current), to: newStatus });
|
|
763
|
+
}
|
|
764
|
+
if (newStatus === AMEND_PENDING_STATUS && (extra.amendsTaskIds?.length ?? 0) === 0) {
|
|
765
|
+
throw new FindingError('findings.amendment-without-obligation', `Finding "${findingId}" cannot enter "${AMEND_PENDING_STATUS}" naming no task ids. An amendment that obligates nothing discharges the finding the moment it is written, which is D-127 — say which added or superseded task this finding is now waiting on. "smith plan amend" derives that list from the plan diff and is the only verb that does (D-136).`, { findingId });
|
|
766
|
+
}
|
|
767
|
+
if (newStatus === AMENDED_STATUS) {
|
|
768
|
+
const outstanding = outstandingObligations(current, extra.amendsSatisfiedBy ?? []);
|
|
769
|
+
if (outstanding === null || outstanding.length > 0) {
|
|
770
|
+
throw new FindingError('findings.amendment-not-discharged', outstanding === null
|
|
771
|
+
? `Finding "${findingId}" is at "${AMEND_PENDING_STATUS}" but names no task ids at a plan version, so no evidence can discharge it. Expire it at the epic boundary instead.`
|
|
772
|
+
: `Finding "${findingId}" cannot close at "${AMENDED_STATUS}": ${outstanding.join(', ')} has not been shown to have landed at plan version ${current.amends_plan_version} or later. An amendment is discharged by its tasks landing, not by being told they did — this is D-127. "smith epic close" computes and supplies that evidence.`, {
|
|
773
|
+
findingId,
|
|
774
|
+
outstanding: outstanding ?? [],
|
|
775
|
+
amendsPlanVersion: current.amends_plan_version,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const { taxonomy } = resolveTaxonomyAndSchemas(opts);
|
|
780
|
+
validateTag(taxonomy, 'finding_status', newStatus);
|
|
781
|
+
const payload = {
|
|
782
|
+
finding_id: findingId,
|
|
783
|
+
fingerprint: current.fingerprint,
|
|
784
|
+
from_status: current.finding_status,
|
|
785
|
+
to_status: newStatus,
|
|
786
|
+
};
|
|
787
|
+
if (extra.waiverId !== undefined)
|
|
788
|
+
payload.waiver_id = extra.waiverId;
|
|
789
|
+
if (extra.waiverRevokedBy !== undefined)
|
|
790
|
+
payload.waiver_revoked_by = extra.waiverRevokedBy;
|
|
791
|
+
if (extra.amendsTaskIds !== undefined)
|
|
792
|
+
payload.amends_task_ids = [...extra.amendsTaskIds];
|
|
793
|
+
if (extra.amendsPlanVersion !== undefined)
|
|
794
|
+
payload.amends_plan_version = extra.amendsPlanVersion;
|
|
795
|
+
// The evidence goes in the log, not on the finding: it is why THIS
|
|
796
|
+
// transition was allowed, and a reader asking "what closed this?" gets the
|
|
797
|
+
// landed tasks and their versions rather than having to re-derive them.
|
|
798
|
+
if (extra.amendsSatisfiedBy !== undefined)
|
|
799
|
+
payload.amends_satisfied_by = extra.amendsSatisfiedBy.map((row) => ({ ...row }));
|
|
800
|
+
await appendEvent({
|
|
801
|
+
session_id: ctx.sessionId,
|
|
802
|
+
actor: ctx.actor ?? 'system',
|
|
803
|
+
event_type: 'finding-transitioned',
|
|
804
|
+
task_id: current.task_id,
|
|
805
|
+
plan_version: ctx.planVersion,
|
|
806
|
+
causal_parent: ctx.causalParent,
|
|
807
|
+
payload,
|
|
808
|
+
}, opts);
|
|
809
|
+
const result = {
|
|
810
|
+
...current,
|
|
811
|
+
finding_status: newStatus,
|
|
812
|
+
...(extra.waiverId !== undefined ? { waiver_id: extra.waiverId } : {}),
|
|
813
|
+
...(extra.amendsTaskIds !== undefined ? { amends_task_ids: [...extra.amendsTaskIds] } : {}),
|
|
814
|
+
...(extra.amendsPlanVersion !== undefined
|
|
815
|
+
? { amends_plan_version: extra.amendsPlanVersion }
|
|
816
|
+
: {}),
|
|
817
|
+
};
|
|
818
|
+
// A reopened finding carrying the id of the waiver that no longer holds it
|
|
819
|
+
// is the same split state D-180 is about, one field smaller.
|
|
820
|
+
if (extra.waiverRevokedBy !== undefined)
|
|
821
|
+
delete result.waiver_id;
|
|
822
|
+
return result;
|
|
823
|
+
}
|
|
824
|
+
// ---------------------------------------------------------------------------
|
|
825
|
+
// Obligation repair (D-21 Part 4)
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
/** Whether `value` is the shape a real task id always has — never a non-empty check alone. */
|
|
828
|
+
/**
|
|
829
|
+
* Whether `value` is the shape a real task id always has -- never a
|
|
830
|
+
* non-empty check alone. Exported: epic.ts's malformed-obligation check
|
|
831
|
+
* (summarizeEpic) has to agree with this definition exactly, or a "" entry
|
|
832
|
+
* reads as well-formed to one reader and corrupt to the other -- which is
|
|
833
|
+
* precisely the D-21 Part 4 review finding this sharing closes. epic.ts is
|
|
834
|
+
* the side that had to move: bareTaskId/taskIdsMatch can never match "" to a
|
|
835
|
+
* real task id downstream, so a looser epic.ts definition would leave this
|
|
836
|
+
* shape permanently unrepairable rather than merely unusual.
|
|
837
|
+
*/
|
|
838
|
+
export function isNonEmptyString(value) {
|
|
839
|
+
return typeof value === 'string' && value.length > 0;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* describeType, but naming an empty string as itself rather than merely
|
|
843
|
+
* "string" -- an empty task id is exactly as malformed as a non-string one,
|
|
844
|
+
* and "string" alone would misname it as fine. Exported for the same reason
|
|
845
|
+
* as isNonEmptyString above: epic.ts's malformed-entries blocker names the
|
|
846
|
+
* bad entry the same way guard 6's refusal does, one register.
|
|
847
|
+
*/
|
|
848
|
+
export function describeMalformedTaskId(value) {
|
|
849
|
+
if (typeof value === 'string' && value.length === 0)
|
|
850
|
+
return 'an empty string';
|
|
851
|
+
return describeType(value);
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* D-21 Part 4. Corrects a malformed `amends_task_ids` entry on a finding
|
|
855
|
+
* parked at `amend-pending` — the shape `f-demo-rpg-reading-interface/
|
|
856
|
+
* integration-3e6bd014` carries (`[null, "…/task-5-reader-memory"]`), written
|
|
857
|
+
* by a `plan amend` call parts 1-3 of D-21 now refuse at the source. The log
|
|
858
|
+
* is append-only, so a record written before that guard existed still holds
|
|
859
|
+
* the malformed entry, and no existing verb can discharge it truthfully:
|
|
860
|
+
* `reverify` is not a status transition, `amend-pending -> expired` asserts
|
|
861
|
+
* the replacement work never landed (it did), a fresh `plan amend` citing the
|
|
862
|
+
* same finding cannot re-enter `amend-pending` from `amend-pending`, and S2 is
|
|
863
|
+
* categorically unwaivable (severity.yml) — the epic deadlocks.
|
|
864
|
+
*
|
|
865
|
+
* This verb can discharge an unwaivable finding, so every one of the six
|
|
866
|
+
* guards below is load-bearing (D-21 part 4 brief):
|
|
867
|
+
* 1. Repairs corruption only — refused unless the CURRENT obligation
|
|
868
|
+
* already holds at least one entry that is not a non-empty string.
|
|
869
|
+
* 2. Cannot weaken — the replacement must retain every well-formed id from
|
|
870
|
+
* the original (taskIdsMatch, D-46/P9-29: either spelling counts).
|
|
871
|
+
* 3. Cannot empty — the resulting obligation must be non-empty; naming
|
|
872
|
+
* nothing discharges the finding on the sentence that wrote it (D-127).
|
|
873
|
+
* 4. Reason required, non-empty — an unaudited repair is not a repair.
|
|
874
|
+
* 5. Only from `amend-pending` — the only status where the obligation is
|
|
875
|
+
* load-bearing.
|
|
876
|
+
* 6. Every replacement id is a non-empty string.
|
|
877
|
+
*
|
|
878
|
+
* Appends a `finding-obligation-repaired` event — never `finding-transitioned`,
|
|
879
|
+
* because a repair changes no status. The fold applies the LATEST such event
|
|
880
|
+
* for a finding onto `amends_task_ids` directly, last-decision-wins, mirroring
|
|
881
|
+
* `isWaived`'s waiver-granted/waiver-denied fold — so `outstandingObligations`
|
|
882
|
+
* (transition()'s own `-> amended` gate) and summarizeEpic both evaluate the
|
|
883
|
+
* repaired list, never the original. `obligation_repair_reason` rides along so
|
|
884
|
+
* a later discharge can be shown to rest on a repaired list rather than
|
|
885
|
+
* quietly dropping the null (epic.ts's SatisfiedAmendment).
|
|
886
|
+
*/
|
|
887
|
+
export async function repairObligation(input, ctx, opts = {}) {
|
|
888
|
+
const events = await readLineageEvents(ctx.sessionId, opts);
|
|
889
|
+
let current;
|
|
890
|
+
for (const { record } of events) {
|
|
891
|
+
if (record.event_type === 'finding-raised') {
|
|
892
|
+
const finding = record.payload;
|
|
893
|
+
if (finding.finding_id === input.findingId)
|
|
894
|
+
current = { ...finding };
|
|
895
|
+
}
|
|
896
|
+
else if (record.event_type === 'finding-transitioned') {
|
|
897
|
+
const payload = record.payload;
|
|
898
|
+
if (payload.finding_id === input.findingId && current) {
|
|
899
|
+
if (payload.waiver_revoked_by)
|
|
900
|
+
delete current.waiver_id;
|
|
901
|
+
current = {
|
|
902
|
+
...current,
|
|
903
|
+
finding_status: payload.to_status,
|
|
904
|
+
...(payload.waiver_id ? { waiver_id: payload.waiver_id } : {}),
|
|
905
|
+
...(payload.amends_task_ids ? { amends_task_ids: payload.amends_task_ids } : {}),
|
|
906
|
+
...(payload.amends_plan_version
|
|
907
|
+
? { amends_plan_version: payload.amends_plan_version }
|
|
908
|
+
: {}),
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
else if (record.event_type === FINDING_OBLIGATION_REPAIRED_EVENT_TYPE) {
|
|
913
|
+
const payload = record.payload;
|
|
914
|
+
if (payload.finding_id === input.findingId && current) {
|
|
915
|
+
current = {
|
|
916
|
+
...current,
|
|
917
|
+
...(payload.to_obligation ? { amends_task_ids: [...payload.to_obligation] } : {}),
|
|
918
|
+
...(payload.reason !== undefined ? { obligation_repair_reason: payload.reason } : {}),
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (!current) {
|
|
924
|
+
throw new FindingError('findings.unknown-finding', `No raised finding with id "${input.findingId}".`, { findingId: input.findingId });
|
|
925
|
+
}
|
|
926
|
+
// Guard 5: only from amend-pending. That is the only status where the
|
|
927
|
+
// obligation is load-bearing -- checked before guard 1 (repairs corruption
|
|
928
|
+
// only) so a finding in the wrong status is refused for that, first, rather
|
|
929
|
+
// than for whatever its (irrelevant) amends_task_ids happens to look like.
|
|
930
|
+
if (current.finding_status !== AMEND_PENDING_STATUS) {
|
|
931
|
+
throw new FindingError('findings.repair-not-pending', `Finding "${input.findingId}" is at "${current.finding_status}", not ` +
|
|
932
|
+
`"${AMEND_PENDING_STATUS}" -- an obligation is only load-bearing while a finding waits ` +
|
|
933
|
+
'on it. Repairing it anywhere else changes a fact nothing is waiting on.', { findingId: input.findingId, findingStatus: current.finding_status });
|
|
934
|
+
}
|
|
935
|
+
const original = current.amends_task_ids ?? [];
|
|
936
|
+
const malformedEntries = [];
|
|
937
|
+
const wellFormedOriginal = [];
|
|
938
|
+
original.forEach((id, index) => {
|
|
939
|
+
if (isNonEmptyString(id))
|
|
940
|
+
wellFormedOriginal.push(id);
|
|
941
|
+
else
|
|
942
|
+
malformedEntries.push({ index, type: describeType(id) });
|
|
943
|
+
});
|
|
944
|
+
// Guard 1: repairs corruption only. This verb repairs malformed data; it
|
|
945
|
+
// never renegotiates a well-formed obligation — that is "smith plan amend"'s
|
|
946
|
+
// job, and it is the one that can compute a real diff against the plan.
|
|
947
|
+
if (malformedEntries.length === 0) {
|
|
948
|
+
throw new FindingError('findings.repair-not-corrupt', `Finding "${input.findingId}"'s obligation names ${original.length} task id(s), all ` +
|
|
949
|
+
'well-formed — there is nothing to repair. This verb repairs malformed amends_task_ids ' +
|
|
950
|
+
'data; it never renegotiates a well-formed obligation. Use "smith plan amend" to change ' +
|
|
951
|
+
'what a finding is waiting on.', { findingId: input.findingId });
|
|
952
|
+
}
|
|
953
|
+
// Guard 4: reason required, non-empty. An unaudited repair is not a repair.
|
|
954
|
+
const reason = input.reason.trim();
|
|
955
|
+
if (reason === '') {
|
|
956
|
+
throw new FindingError('findings.repair-reason-required', `Repairing finding "${input.findingId}"'s obligation needs a non-empty --reason: an ` +
|
|
957
|
+
'unaudited repair is not a repair.', { findingId: input.findingId });
|
|
958
|
+
}
|
|
959
|
+
// Guard 3: cannot empty. Checked before guard 2 (cannot weaken) so an empty
|
|
960
|
+
// replacement is named for what it is -- naming nothing -- rather than as
|
|
961
|
+
// "dropped every well-formed id", which is true but not the clearest thing
|
|
962
|
+
// to tell the caller first.
|
|
963
|
+
if (input.replaceWith.length === 0) {
|
|
964
|
+
throw new FindingError('findings.repair-would-empty', `Refusing to repair finding "${input.findingId}"'s obligation to an empty list: an ` +
|
|
965
|
+
'obligation naming nothing discharges the finding it names on the sentence that wrote ' +
|
|
966
|
+
'it (D-127) -- name at least one task id.', { findingId: input.findingId });
|
|
967
|
+
}
|
|
968
|
+
// Guard 2: cannot weaken. The replacement must retain every well-formed id
|
|
969
|
+
// from the original -- it may drop only malformed entries, and it may add.
|
|
970
|
+
// Without this, "repair" becomes a way to delete a real obligation.
|
|
971
|
+
const dropped = wellFormedOriginal.filter((id) => !input.replaceWith.some((replacement) => taskIdsMatch(id, replacement)));
|
|
972
|
+
if (dropped.length > 0) {
|
|
973
|
+
throw new FindingError('findings.repair-would-weaken', `Refusing to repair finding "${input.findingId}"'s obligation: the replacement drops ` +
|
|
974
|
+
`${dropped.join(', ')}, which the original obligation already named and is well-formed. ` +
|
|
975
|
+
'A repair may drop only malformed entries and may add, never drop a real obligation.', { findingId: input.findingId, dropped });
|
|
976
|
+
}
|
|
977
|
+
// Guard 6: every replacement id is a non-empty string. Same describeType
|
|
978
|
+
// error style as part 1 -- name the type and the index, never the value.
|
|
979
|
+
// describeMalformedTaskId also names an empty string as itself, not merely
|
|
980
|
+
// "string" -- an empty entry is exactly as malformed as a non-string one.
|
|
981
|
+
input.replaceWith.forEach((id, index) => {
|
|
982
|
+
if (!isNonEmptyString(id)) {
|
|
983
|
+
throw new FindingError('findings.repair-replacement-not-string', `--replace-with[${index}] is ${describeMalformedTaskId(id)}, not a task id. Every ` +
|
|
984
|
+
'replacement entry must be a non-empty string.', { findingId: input.findingId, index, received: describeMalformedTaskId(id) });
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
const toObligation = [...input.replaceWith];
|
|
988
|
+
const payload = {
|
|
989
|
+
finding_id: input.findingId,
|
|
990
|
+
from_obligation: original,
|
|
991
|
+
to_obligation: toObligation,
|
|
992
|
+
malformed_entries: malformedEntries,
|
|
993
|
+
reason,
|
|
994
|
+
};
|
|
995
|
+
if (current.amends_plan_version !== undefined) {
|
|
996
|
+
payload.amends_plan_version = current.amends_plan_version;
|
|
997
|
+
}
|
|
998
|
+
await appendEvent({
|
|
999
|
+
session_id: ctx.sessionId,
|
|
1000
|
+
actor: ctx.actor ?? 'user',
|
|
1001
|
+
event_type: FINDING_OBLIGATION_REPAIRED_EVENT_TYPE,
|
|
1002
|
+
task_id: current.task_id,
|
|
1003
|
+
plan_version: ctx.planVersion,
|
|
1004
|
+
causal_parent: ctx.causalParent,
|
|
1005
|
+
payload,
|
|
1006
|
+
}, opts);
|
|
1007
|
+
return { ...current, amends_task_ids: toObligation, obligation_repair_reason: reason };
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Which open findings are answering about code that no longer exists.
|
|
1011
|
+
*
|
|
1012
|
+
* A finding's evidence — the failure scenario, the summary, the line it points
|
|
1013
|
+
* at — is a claim about one file at one moment. Once a wave merges over that
|
|
1014
|
+
* file, the evidence describes code that has been rewritten, and a waiver
|
|
1015
|
+
* granted from it is a decision made on deleted evidence. This reports the
|
|
1016
|
+
* findings in that state so the gate can demand a re-read first.
|
|
1017
|
+
*
|
|
1018
|
+
* Strictly ordering-based: a finding is stale iff the last `wave-merged`
|
|
1019
|
+
* touching its file comes *after* both its raise and its most recent
|
|
1020
|
+
* `finding-reverified`. Reading positions out of one ordered log is what makes
|
|
1021
|
+
* "re-verified, then merged over again" come out stale — a timestamp
|
|
1022
|
+
* comparison across separately-clocked producers would not.
|
|
1023
|
+
*
|
|
1024
|
+
* Over the lineage since D-119, and that caution survives the change: within a
|
|
1025
|
+
* session the positions are still the writer's own append order, and only
|
|
1026
|
+
* BETWEEN sessions does `readLineageEvents` fall back to the wall clock, which
|
|
1027
|
+
* is the only reference two sessions share. The alternative was leaving a
|
|
1028
|
+
* continuation blind to every merge its parent recorded, which reports a
|
|
1029
|
+
* finding raised against long-rewritten code as fresh evidence.
|
|
1030
|
+
*/
|
|
1031
|
+
export async function staleFindings(sessionId, opts = {}) {
|
|
1032
|
+
const events = await readLineageEvents(sessionId, opts);
|
|
1033
|
+
const raisedAt = new Map();
|
|
1034
|
+
const reverifiedAt = new Map();
|
|
1035
|
+
const merges = [];
|
|
1036
|
+
events.forEach(({ record }, index) => {
|
|
1037
|
+
if (record.event_type === 'finding-raised') {
|
|
1038
|
+
const finding = record.payload;
|
|
1039
|
+
raisedAt.set(finding.finding_id, { index, finding });
|
|
1040
|
+
}
|
|
1041
|
+
else if (record.event_type === 'finding-reverified') {
|
|
1042
|
+
const payload = record.payload;
|
|
1043
|
+
if (payload.finding_id)
|
|
1044
|
+
reverifiedAt.set(payload.finding_id, index);
|
|
1045
|
+
}
|
|
1046
|
+
else if (record.event_type === 'wave-merged' && record.task_id) {
|
|
1047
|
+
const payload = record.payload;
|
|
1048
|
+
merges.push({
|
|
1049
|
+
index,
|
|
1050
|
+
taskId: record.task_id,
|
|
1051
|
+
...(Array.isArray(payload.files_changed)
|
|
1052
|
+
? { filesChanged: payload.files_changed }
|
|
1053
|
+
: {}),
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
if (raisedAt.size === 0 || merges.length === 0)
|
|
1058
|
+
return new Map();
|
|
1059
|
+
// Only loaded when a merge actually needs the fallback, so the common path
|
|
1060
|
+
// (every wave-merged carries its file list) costs nothing.
|
|
1061
|
+
let claimsByTask;
|
|
1062
|
+
const claimsFor = async (taskId) => {
|
|
1063
|
+
if (claimsByTask === undefined) {
|
|
1064
|
+
const added = await readAddedTasks({ sessionId }, opts);
|
|
1065
|
+
claimsByTask = new Map(added.map((task) => [task.taskId, task.claims]));
|
|
1066
|
+
}
|
|
1067
|
+
// Empty is the safe direction for THIS question, unlike the wave gate's:
|
|
1068
|
+
// this list decides whether a merge could have rewritten the file a
|
|
1069
|
+
// finding names, and a task whose claims cannot be read is one this cannot
|
|
1070
|
+
// show rewrote anything — so the finding stays answerable rather than
|
|
1071
|
+
// being marked stale on evidence nobody has.
|
|
1072
|
+
const claims = claimsByTask.get(taskId);
|
|
1073
|
+
return Array.isArray(claims) ? claims.filter((c) => typeof c === 'string') : [];
|
|
1074
|
+
};
|
|
1075
|
+
const stale = new Map();
|
|
1076
|
+
for (const [findingId, { index: raisedIndex, finding }] of raisedAt) {
|
|
1077
|
+
const freshUntil = Math.max(raisedIndex, reverifiedAt.get(findingId) ?? -1);
|
|
1078
|
+
// Last merge wins: it names the most recent rewrite the operator has to
|
|
1079
|
+
// answer for, and reporting an older one would point at the wrong task.
|
|
1080
|
+
// Both bases below are joins on the path, so a finding that never carried
|
|
1081
|
+
// one is unanswerable here rather than fresh or stale (D-191). Skipped, not
|
|
1082
|
+
// dereferenced: the claims branch used to throw on it.
|
|
1083
|
+
const filePath = finding.file_path;
|
|
1084
|
+
if (filePath === undefined)
|
|
1085
|
+
continue;
|
|
1086
|
+
for (const merge of merges) {
|
|
1087
|
+
if (merge.index <= freshUntil)
|
|
1088
|
+
continue;
|
|
1089
|
+
if (merge.filesChanged) {
|
|
1090
|
+
const hit = merge.filesChanged.find((changed) => normalizeFilePath(changed) === filePath);
|
|
1091
|
+
if (hit === undefined)
|
|
1092
|
+
continue;
|
|
1093
|
+
stale.set(findingId, {
|
|
1094
|
+
findingId,
|
|
1095
|
+
filePath,
|
|
1096
|
+
mergedTaskId: merge.taskId,
|
|
1097
|
+
basis: 'files-changed',
|
|
1098
|
+
matched: hit,
|
|
1099
|
+
});
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
const claim = (await claimsFor(merge.taskId)).find((glob) => claimCoversPath(glob, filePath));
|
|
1103
|
+
if (claim === undefined)
|
|
1104
|
+
continue;
|
|
1105
|
+
stale.set(findingId, {
|
|
1106
|
+
findingId,
|
|
1107
|
+
filePath,
|
|
1108
|
+
mergedTaskId: merge.taskId,
|
|
1109
|
+
basis: 'claims',
|
|
1110
|
+
matched: claim,
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return stale;
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Record that a finding's evidence was re-read against the current code.
|
|
1118
|
+
*
|
|
1119
|
+
* Deliberately not a `finding_status` transition: `confirmed` cannot be
|
|
1120
|
+
* re-entered and `refuted` means the finding was wrong. Neither says "someone
|
|
1121
|
+
* looked again after the file was rewritten and the finding still stands",
|
|
1122
|
+
* which is the only thing that can un-stale evidence. So it is its own event,
|
|
1123
|
+
* and the status machine keeps meaning exactly what it meant before.
|
|
1124
|
+
*/
|
|
1125
|
+
export async function reverifyFinding(findingId, note, ctx, opts = {}) {
|
|
1126
|
+
// Lineage (D-119): staleFindings reads the lineage, so the findings it names
|
|
1127
|
+
// as stale have to be re-verifiable from the session that was told about them.
|
|
1128
|
+
const events = await readLineageEvents(ctx.sessionId, opts);
|
|
1129
|
+
let current;
|
|
1130
|
+
for (const { record } of events) {
|
|
1131
|
+
if (record.event_type !== 'finding-raised')
|
|
1132
|
+
continue;
|
|
1133
|
+
const finding = record.payload;
|
|
1134
|
+
if (finding.finding_id === findingId)
|
|
1135
|
+
current = finding;
|
|
1136
|
+
}
|
|
1137
|
+
if (!current) {
|
|
1138
|
+
throw new FindingError('findings.unknown-finding', `No raised finding with id "${findingId}".`, {
|
|
1139
|
+
findingId,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
await appendEvent({
|
|
1143
|
+
session_id: ctx.sessionId,
|
|
1144
|
+
actor: ctx.actor ?? 'system',
|
|
1145
|
+
event_type: 'finding-reverified',
|
|
1146
|
+
task_id: current.task_id,
|
|
1147
|
+
plan_version: ctx.planVersion,
|
|
1148
|
+
causal_parent: ctx.causalParent,
|
|
1149
|
+
payload: {
|
|
1150
|
+
finding_id: findingId,
|
|
1151
|
+
fingerprint: current.fingerprint,
|
|
1152
|
+
file_path: current.file_path,
|
|
1153
|
+
note,
|
|
1154
|
+
},
|
|
1155
|
+
}, opts);
|
|
1156
|
+
}
|