@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,399 @@
|
|
|
1
|
+
import { appendEdge, appendEvent, readLineageEvents, } from './events.js';
|
|
2
|
+
import { resolveTaskId } from './plan.js';
|
|
3
|
+
import { taskBranchName } from './worktree.js';
|
|
4
|
+
function envelope(ctx, eventType, payload, taskId) {
|
|
5
|
+
return {
|
|
6
|
+
session_id: ctx.sessionId,
|
|
7
|
+
actor: ctx.actor ?? 'system',
|
|
8
|
+
event_type: eventType,
|
|
9
|
+
plan_version: ctx.planVersion,
|
|
10
|
+
causal_parent: ctx.causalParent,
|
|
11
|
+
payload,
|
|
12
|
+
...(taskId ? { task_id: taskId } : {}),
|
|
13
|
+
...(ctx.project ? { project: ctx.project } : {}),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The project this write belongs to: what the caller declared, else what the
|
|
18
|
+
* plan does.
|
|
19
|
+
*
|
|
20
|
+
* D-232. Every plan file under factory/specs/ carries a `project`, and until
|
|
21
|
+
* now nothing at runtime read it -- `plan ingest` handed the plan straight to
|
|
22
|
+
* emitTasksAdded, which used it for the epic id and the task specs and let the
|
|
23
|
+
* project fall on the floor. Every event of a demo-rpg run therefore landed
|
|
24
|
+
* with no project at all, and db/queries.ts's projectOf() resolves an absent
|
|
25
|
+
* one to the default: a whole epic filed under black-smith, missing from the
|
|
26
|
+
* board of the project it was actually part of.
|
|
27
|
+
*
|
|
28
|
+
* The plan is the fallback and not an override, because a caller holding a
|
|
29
|
+
* project knows something the file cannot -- and an absent value stays absent
|
|
30
|
+
* rather than becoming the default here, which is events.ts's rule: the log
|
|
31
|
+
* records what was stamped, and the read helpers do the resolving.
|
|
32
|
+
*/
|
|
33
|
+
function planScoped(ctx, plan) {
|
|
34
|
+
const project = ctx.project ?? plan.project;
|
|
35
|
+
return project === undefined ? ctx : { ...ctx, project };
|
|
36
|
+
}
|
|
37
|
+
function addedPayload(plan, task) {
|
|
38
|
+
const budget = task.budget;
|
|
39
|
+
// A task spec is a `Record<string, unknown>`, so its `epic_id` is only a
|
|
40
|
+
// string by convention. `taskBranchName` needs a real one, and a task
|
|
41
|
+
// carrying anything else belongs to the plan's epic by default.
|
|
42
|
+
const epicId = typeof task.epic_id === 'string' ? task.epic_id : plan.epic_id;
|
|
43
|
+
return {
|
|
44
|
+
epic_id: epicId,
|
|
45
|
+
case: task.case,
|
|
46
|
+
origin: task.origin,
|
|
47
|
+
task_status: task.task_status,
|
|
48
|
+
plan_version: task.plan_version,
|
|
49
|
+
objective: task.objective,
|
|
50
|
+
claims: task.claims,
|
|
51
|
+
budget_tokens: budget?.tokens,
|
|
52
|
+
// D-23/P9-12. Declared here rather than derived by the reader: the board
|
|
53
|
+
// wants a branch link the moment a task is added, which is well before a
|
|
54
|
+
// worktree exists, and worktree.ts's convention is the one that decides
|
|
55
|
+
// what `smith worktree create` will actually cut. Reading it off the same
|
|
56
|
+
// function that creates it means the two cannot say different things.
|
|
57
|
+
branch: taskBranchName(epicId, task.task_id),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Collect the task ids that already have an event of `eventType` in this
|
|
62
|
+
* session's LINEAGE. Ingesting a plan twice — a re-run, a resumed session, v2
|
|
63
|
+
* of a plan that carries v1's tasks forward — must not double-write history,
|
|
64
|
+
* and the log itself is the only honest record of what has already been said.
|
|
65
|
+
*
|
|
66
|
+
* The lineage and not one session, since D-119: "a resumed session" is exactly
|
|
67
|
+
* the case a session-scoped read got wrong. Re-ingesting the plan from a
|
|
68
|
+
* continuation found none of the parent's `task-added` events and wrote the
|
|
69
|
+
* whole plan a second time, which is the double-write this guard is for.
|
|
70
|
+
*/
|
|
71
|
+
async function idsAlreadyEmitted(eventType, ctx, opts) {
|
|
72
|
+
const events = await readLineageEvents(ctx.sessionId, opts);
|
|
73
|
+
const ids = new Set();
|
|
74
|
+
for (const { record } of events) {
|
|
75
|
+
if (record.event_type === eventType && record.task_id)
|
|
76
|
+
ids.add(record.task_id);
|
|
77
|
+
}
|
|
78
|
+
return ids;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The highest `plan_version` each task id has already been *added* under.
|
|
82
|
+
*
|
|
83
|
+
* D-18. `idsAlreadyEmitted` answers "has this id been added at all", which is
|
|
84
|
+
* the wrong question after an amendment: `draftNextVersion` supersedes a task
|
|
85
|
+
* by REUSING its id (D-121), so v(n+1) carries the dead copy beside a live one
|
|
86
|
+
* at the higher version. Keyed on the id alone, that live record is invisible,
|
|
87
|
+
* the amended task keeps the `plan_version` it was first added under, and
|
|
88
|
+
* `satisfiedAmendments` (epic.ts) — which requires `planVersion >= version` for
|
|
89
|
+
* every id the amendment names — can never discharge the finding that forced
|
|
90
|
+
* the amendment. Epic 1 read that deadlock as "ingest was never run"; running
|
|
91
|
+
* it correctly, at both versions, still deadlocks without this.
|
|
92
|
+
*
|
|
93
|
+
* A missing or non-numeric `plan_version` reads as `-Infinity`, so a task added
|
|
94
|
+
* before this field was carried is re-added once by any plan that names one
|
|
95
|
+
* rather than being frozen out.
|
|
96
|
+
*/
|
|
97
|
+
async function addedPlanVersions(ctx, opts) {
|
|
98
|
+
const events = await readLineageEvents(ctx.sessionId, opts);
|
|
99
|
+
const highest = new Map();
|
|
100
|
+
for (const { record } of events) {
|
|
101
|
+
if (record.event_type !== 'task-added' || !record.task_id)
|
|
102
|
+
continue;
|
|
103
|
+
const raw = record.payload.plan_version;
|
|
104
|
+
const version = typeof raw === 'number' && Number.isFinite(raw) ? raw : Number.NEGATIVE_INFINITY;
|
|
105
|
+
const seen = highest.get(record.task_id);
|
|
106
|
+
if (seen === undefined || version > seen)
|
|
107
|
+
highest.set(record.task_id, version);
|
|
108
|
+
}
|
|
109
|
+
return highest;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Read the lineage's log as a register of tasks — every id a `task-added`
|
|
113
|
+
* has introduced, with the claims it was introduced with.
|
|
114
|
+
*
|
|
115
|
+
* D-48/P9-31: a follow-up minted by `findings raise` exists in the log and in
|
|
116
|
+
* no plan file, so the log has to be readable as a register or that task is
|
|
117
|
+
* unaddressable by every producer that resolves through a plan. Claims are
|
|
118
|
+
* carried, not just ids: a wave admitting a task with an empty claim set has
|
|
119
|
+
* admitted a task allowed to touch nothing, which fails at the first edit
|
|
120
|
+
* rather than at admission, where the mistake actually is. They are carried
|
|
121
|
+
* AS WRITTEN for the same reason: substituting `[]` for a claims value this
|
|
122
|
+
* could not read handed `validateWave` the one claim set that overlaps
|
|
123
|
+
* nothing, so an unreadable task was admitted as a disjoint one.
|
|
124
|
+
*
|
|
125
|
+
* Later events win on a repeated id (a re-add carries the newer claims), and
|
|
126
|
+
* first-appearance order is kept so the register reads in log order.
|
|
127
|
+
*
|
|
128
|
+
* Lineage-wide (D-119). This register is what makes a follow-up task
|
|
129
|
+
* addressable at all, and a task the parent session minted does not stop
|
|
130
|
+
* existing because the epic continued somewhere else — a claims check that
|
|
131
|
+
* cannot find the task it is checking is a claims check that passes.
|
|
132
|
+
*/
|
|
133
|
+
export async function readAddedTasks(
|
|
134
|
+
// Reading takes only the session — a caller that is not writing should not
|
|
135
|
+
// have to invent a causal parent to ask what exists.
|
|
136
|
+
ctx, opts = {}) {
|
|
137
|
+
const events = await readLineageEvents(ctx.sessionId, opts);
|
|
138
|
+
const byId = new Map();
|
|
139
|
+
for (const { record } of events) {
|
|
140
|
+
if (record.event_type !== 'task-added' || !record.task_id)
|
|
141
|
+
continue;
|
|
142
|
+
byId.set(record.task_id, { taskId: record.task_id, claims: record.payload?.claims });
|
|
143
|
+
}
|
|
144
|
+
return [...byId.values()];
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* One record per task id, in plan order: the id's live spec, or its last
|
|
148
|
+
* record when every record for the id is dead.
|
|
149
|
+
*
|
|
150
|
+
* D-184. `plan.tasks` is not a backlog. `draftNextVersion` keeps each
|
|
151
|
+
* superseded copy of a task *beside* the record that replaced it, under the
|
|
152
|
+
* same `task_id` (D-121), so a v(n+1) file is routinely longer than the work
|
|
153
|
+
* it describes — envkit-mcp-surface's plan-v5.json holds 13 records for 5
|
|
154
|
+
* live tasks, four ids appearing three times each. This is the same rule
|
|
155
|
+
* `liveSpec`/`livePlanTasks` apply in plan.ts, and for the same reason D-126
|
|
156
|
+
* gave: reading the raw list answers with whichever copy happened to be
|
|
157
|
+
* written last. It is repeated here rather than imported because the dead
|
|
158
|
+
* ids matter to this caller — `livePlanTasks` drops them, and an id whose
|
|
159
|
+
* every record is dead is exactly the one that needs a `task-superseded`.
|
|
160
|
+
*/
|
|
161
|
+
function specsToIngest(plan) {
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
const specs = [];
|
|
164
|
+
for (const t of plan.tasks) {
|
|
165
|
+
if (seen.has(t.task_id))
|
|
166
|
+
continue;
|
|
167
|
+
seen.add(t.task_id);
|
|
168
|
+
const records = plan.tasks.filter((r) => r.task_id === t.task_id);
|
|
169
|
+
const live = records.filter((r) => r.task_status !== 'superseded');
|
|
170
|
+
// Last, not first: among the records still alive the later one is the
|
|
171
|
+
// amendment, which is what `liveSpec` means by "the live spec". `?? t`
|
|
172
|
+
// is unreachable — `records` always holds `t` — and is written rather
|
|
173
|
+
// than asserted so the array access needs no cast to stay honest.
|
|
174
|
+
const pool = live.length > 0 ? live : records;
|
|
175
|
+
specs.push(pool[pool.length - 1] ?? t);
|
|
176
|
+
}
|
|
177
|
+
return specs;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Write the plan's backlog into the log: one `task-added` per task id the
|
|
181
|
+
* session has not already recorded, plus a `task-superseded` for any id the
|
|
182
|
+
* plan has no live spec left for (a v(n+1) cut records that as plan state,
|
|
183
|
+
* and the projector needs it as an event to reach the same conclusion).
|
|
184
|
+
*
|
|
185
|
+
* Per id, not per record — see `specsToIngest`. Emitting the dead copies too
|
|
186
|
+
* would say two false things at once: that a task the plan still lists as
|
|
187
|
+
* `todo` was superseded, and that a 5-task backlog is 13 tasks.
|
|
188
|
+
*
|
|
189
|
+
* Returns only the events it actually appended, so a caller can report "5
|
|
190
|
+
* added, 0 already present" truthfully rather than guessing from the plan.
|
|
191
|
+
*/
|
|
192
|
+
export async function emitTasksAdded(plan, ctx, opts = {}) {
|
|
193
|
+
const added = await addedPlanVersions(ctx, opts);
|
|
194
|
+
const superseded = await idsAlreadyEmitted('task-superseded', ctx, opts);
|
|
195
|
+
const scoped = planScoped(ctx, plan);
|
|
196
|
+
const written = [];
|
|
197
|
+
// The ids the plan marks as superseded-and-replaced: a dead record beside a
|
|
198
|
+
// live one under the same id is how `draftNextVersion` records an amendment.
|
|
199
|
+
const supersededIds = new Set(plan.tasks.filter((r) => r.task_status === 'superseded').map((r) => r.task_id));
|
|
200
|
+
for (const task of specsToIngest(plan)) {
|
|
201
|
+
const taskId = task.task_id;
|
|
202
|
+
// Re-add only when the live spec has genuinely moved forward: an id absent
|
|
203
|
+
// from the log, or one whose live record now carries a HIGHER plan_version
|
|
204
|
+
// than anything added for it. Equal versions stay idempotent — the same
|
|
205
|
+
// plan ingested twice is still one `task-added` — and a lower version is a
|
|
206
|
+
// stale file, not an amendment, so it is ignored rather than honoured.
|
|
207
|
+
const recorded = added.get(taskId);
|
|
208
|
+
const liveVersion = task.plan_version;
|
|
209
|
+
// D-18b. A higher version alone is not enough: `draftNextVersion` restamps
|
|
210
|
+
// EVERY task with the new plan_version, carried ones included, so keying on
|
|
211
|
+
// the number would re-add tasks whose spec never changed. The amendment's
|
|
212
|
+
// own marker is what counts — an id the amendment touched carries a dead
|
|
213
|
+
// record beside its live one, which is exactly what `supersededIds` holds.
|
|
214
|
+
const amended = recorded !== undefined &&
|
|
215
|
+
supersededIds.has(taskId) &&
|
|
216
|
+
typeof liveVersion === 'number' &&
|
|
217
|
+
liveVersion > recorded;
|
|
218
|
+
if (recorded === undefined || amended) {
|
|
219
|
+
written.push(await appendEvent(envelope(scoped, 'task-added', addedPayload(plan, task), taskId), opts));
|
|
220
|
+
}
|
|
221
|
+
if (task.task_status === 'superseded' && !superseded.has(taskId)) {
|
|
222
|
+
written.push(await appendEvent(envelope(scoped, 'task-superseded', { epic_id: plan.epic_id }, taskId), opts));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return written;
|
|
226
|
+
}
|
|
227
|
+
/** One arrow's identity in the log: who depends on whom, and in what sense. */
|
|
228
|
+
function edgeKey(task, dependsOn, edgeType) {
|
|
229
|
+
return `${task}\u0000${dependsOn}\u0000${edgeType}`;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The edges this session's LINEAGE has already recorded, keyed by the triple
|
|
233
|
+
* `edgeKey` spells rather than by task id the way `idsAlreadyEmitted` is.
|
|
234
|
+
*
|
|
235
|
+
* A task has as many arrows as it has dependencies, so a task-keyed register
|
|
236
|
+
* would record the first and drop the rest. And the third component is not
|
|
237
|
+
* decoration: demo-rpg-chapter-reading's plan-v1 declares both an `artifact`
|
|
238
|
+
* and a `claim-order` handoff between the same two tasks, which are two
|
|
239
|
+
* different claims about one pair and belong in the log as two events.
|
|
240
|
+
*
|
|
241
|
+
* The lineage and not one session, for D-119's reason -- a resumed session
|
|
242
|
+
* re-ingests the plan it was resumed into, and a session-scoped read would
|
|
243
|
+
* see none of what its parent already wrote.
|
|
244
|
+
*/
|
|
245
|
+
async function edgesAlreadyRecorded(ctx, opts) {
|
|
246
|
+
const events = await readLineageEvents(ctx.sessionId, opts);
|
|
247
|
+
const keys = new Set();
|
|
248
|
+
for (const { record } of events) {
|
|
249
|
+
if (record.event_type !== 'edge-recorded' || !record.task_id || !record.edge)
|
|
250
|
+
continue;
|
|
251
|
+
const dependsOn = record.payload.depends_on;
|
|
252
|
+
if (typeof dependsOn !== 'string')
|
|
253
|
+
continue;
|
|
254
|
+
keys.add(edgeKey(record.task_id, dependsOn, record.edge.edge_type));
|
|
255
|
+
}
|
|
256
|
+
return keys;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Write the plan's DAG into the log: one `edge-recorded` per dependency the
|
|
260
|
+
* plan declares that this lineage has not already recorded.
|
|
261
|
+
*
|
|
262
|
+
* D-254. `emitTasksAdded` above ingests the nodes; this ingests the arrows,
|
|
263
|
+
* and until it existed nothing in production ever wrote an `edge-recorded`
|
|
264
|
+
* event at all. The consequence was not a missing feature but a wrong answer:
|
|
265
|
+
* the db's `edges` table was empty in every real session, so the Flow page
|
|
266
|
+
* drew a DAG with no dependencies and laid all 26 tasks out in a single wave,
|
|
267
|
+
* and the Roadmap's mini-timeline reported `dependencyReady` for every next-up
|
|
268
|
+
* task -- vacuously true over an empty dependency list. The scheduler was
|
|
269
|
+
* never wrong, because it reads `plan.edges` from the file directly; only
|
|
270
|
+
* every surface an operator looks at was.
|
|
271
|
+
*
|
|
272
|
+
* Both ends are resolved against the plan before anything is appended, so a
|
|
273
|
+
* plan whose DAG names a task it does not contain is refused whole. That is
|
|
274
|
+
* `emitWaveAdmitted`'s rule and `validatePlan`'s rule; an arrow written to a
|
|
275
|
+
* ghost would be dropped silently by every reader instead of seen by anyone.
|
|
276
|
+
*/
|
|
277
|
+
export async function emitEdgesRecorded(plan, ctx, opts = {}) {
|
|
278
|
+
const resolved = plan.edges.map((e) => ({
|
|
279
|
+
task: resolveTaskId(plan, e.task),
|
|
280
|
+
dependsOn: resolveTaskId(plan, e.dependsOn),
|
|
281
|
+
edge_type: e.edge_type,
|
|
282
|
+
edge_provenance: e.edge_provenance,
|
|
283
|
+
}));
|
|
284
|
+
const recorded = await edgesAlreadyRecorded(ctx, opts);
|
|
285
|
+
const scoped = planScoped(ctx, plan);
|
|
286
|
+
const written = [];
|
|
287
|
+
for (const edge of resolved) {
|
|
288
|
+
const key = edgeKey(edge.task, edge.dependsOn, edge.edge_type);
|
|
289
|
+
// Guards the plan against itself as well as against a re-ingest: an edge
|
|
290
|
+
// the file states twice verbatim says nothing the first one did not.
|
|
291
|
+
if (recorded.has(key))
|
|
292
|
+
continue;
|
|
293
|
+
recorded.add(key);
|
|
294
|
+
written.push(await appendEdge(envelope(scoped, 'edge-recorded', { epic_id: plan.epic_id, depends_on: edge.dependsOn }, edge.task), { edge_type: edge.edge_type, edge_provenance: edge.edge_provenance }, opts));
|
|
295
|
+
}
|
|
296
|
+
return written;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Record that a wave was admitted. Every id is resolved against the plan and
|
|
300
|
+
* the log's own register BEFORE anything is appended, so a wave naming one
|
|
301
|
+
* task that does not exist is refused whole rather than half-written — a
|
|
302
|
+
* partially admitted wave is precisely the state that has no owner and no way
|
|
303
|
+
* back. The register is read here rather than passed in (D-48/P9-31) so no
|
|
304
|
+
* caller can forget it and quietly lose the ability to admit a follow-up.
|
|
305
|
+
*/
|
|
306
|
+
export async function emitWaveAdmitted(plan, typedIds, ctx, opts = {}, budget) {
|
|
307
|
+
const logged = (await readAddedTasks(ctx, opts)).map((t) => t.taskId);
|
|
308
|
+
const taskIds = typedIds.map((typed) => resolveTaskId(plan, typed, logged));
|
|
309
|
+
return appendEvent(envelope(planScoped(ctx, plan), 'wave-admitted', {
|
|
310
|
+
epic_id: plan.epic_id,
|
|
311
|
+
task_ids: taskIds,
|
|
312
|
+
...(budget ? { budget } : {}),
|
|
313
|
+
}), opts);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Record that one task's branch landed on the integration branch. One event
|
|
317
|
+
* per task, carrying a single-element `task_ids`, rather than one event per
|
|
318
|
+
* wave: a merge queue that gets three tasks in and fails on the fourth has
|
|
319
|
+
* genuinely merged three, and the log should say so. A wave-shaped event
|
|
320
|
+
* could only be written after the whole wave finished, which is exactly when
|
|
321
|
+
* a crash would lose it.
|
|
322
|
+
*
|
|
323
|
+
* `filesChanged` is what the merge actually rewrote (P9-15). Optional because
|
|
324
|
+
* only the queue can compute it and only after the merge commit exists; a
|
|
325
|
+
* caller that cannot supply it writes the event without the field rather than
|
|
326
|
+
* with an empty list, so "no file list here" and "this merge changed nothing"
|
|
327
|
+
* stay distinguishable to the staleness check reading them back.
|
|
328
|
+
*/
|
|
329
|
+
export async function emitWaveMerged(taskId, ctx, opts = {}, filesChanged) {
|
|
330
|
+
const payload = { task_ids: [taskId] };
|
|
331
|
+
if (filesChanged !== undefined)
|
|
332
|
+
payload.files_changed = [...filesChanged];
|
|
333
|
+
return appendEvent(envelope(ctx, 'wave-merged', payload, taskId), opts);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Record that a task is blocked by a classified error. `error` must be a
|
|
337
|
+
* taxonomy error class — appendEvent enforces that, and a `coordination.`
|
|
338
|
+
* prefix is what makes the projector escalate rather than block, so a
|
|
339
|
+
* mis-typed class is a wrong status, not just a wrong label.
|
|
340
|
+
*/
|
|
341
|
+
export async function emitTaskBlocked(taskId, error, ctx, opts = {}) {
|
|
342
|
+
return appendEvent(envelope(ctx, 'error-logged', {
|
|
343
|
+
error: error.error,
|
|
344
|
+
severity: error.severity,
|
|
345
|
+
task_ref: taskId,
|
|
346
|
+
...(error.detail === undefined ? {} : { detail: error.detail }),
|
|
347
|
+
}, taskId), opts);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The id of the follow-up task an unownable finding opens against its epic
|
|
351
|
+
* (D-41/P9-24). Derived from the fingerprint so it is stable: re-running the
|
|
352
|
+
* same gate over the same evidence re-derives the same id and
|
|
353
|
+
* `emitFollowUpTask` recognises it as already written, rather than minting a
|
|
354
|
+
* second task for one bug.
|
|
355
|
+
*
|
|
356
|
+
* The `followup-` prefix keeps it clear of db/projector.ts's `isReservedRef`
|
|
357
|
+
* guard, which drops any id whose last segment is `integration` or a
|
|
358
|
+
* `plan-v<n>` plan ref — a follow-up that the projector refused to
|
|
359
|
+
* materialise would block nothing.
|
|
360
|
+
*/
|
|
361
|
+
export function followUpTaskId(epicId, fingerprint) {
|
|
362
|
+
return `${epicId}/followup-${fingerprint.slice(0, 8)}`;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Record a follow-up task for a finding no open task can own: the file's
|
|
366
|
+
* owner already merged, two tasks claim it equally, or nobody claims it at
|
|
367
|
+
* all. `origin: escalation` because that is literally what happened — the
|
|
368
|
+
* gate could not settle it and handed it up to the epic.
|
|
369
|
+
*
|
|
370
|
+
* Returns null when the session already carries this task, so a re-run is a
|
|
371
|
+
* no-op rather than a duplicate. The task is `todo`, which is not terminal-OK,
|
|
372
|
+
* so epic.ts's `summarizeEpic` blocks the epic verdict on it until an operator
|
|
373
|
+
* plans or waives it — the "blocks the epic verdict instead of an unrelated
|
|
374
|
+
* diff" half of P9-24, using the machinery that was already there.
|
|
375
|
+
*/
|
|
376
|
+
export async function emitFollowUpTask(input, ctx, opts = {}) {
|
|
377
|
+
const added = await idsAlreadyEmitted('task-added', ctx, opts);
|
|
378
|
+
if (added.has(input.taskId))
|
|
379
|
+
return null;
|
|
380
|
+
return appendEvent(envelope(ctx, 'task-added', {
|
|
381
|
+
epic_id: input.epicId,
|
|
382
|
+
case: 'bugfix',
|
|
383
|
+
origin: 'escalation',
|
|
384
|
+
task_status: 'todo',
|
|
385
|
+
plan_version: ctx.planVersion,
|
|
386
|
+
objective: input.objective,
|
|
387
|
+
claims: input.claims,
|
|
388
|
+
}, input.taskId), opts);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Record that a task was replaced by a later plan version. Resolves against
|
|
392
|
+
* the log's register too (D-48/P9-31): superseding is one of the two terminal
|
|
393
|
+
* exits a follow-up needs, and a follow-up is never in the plan.
|
|
394
|
+
*/
|
|
395
|
+
export async function emitTaskSuperseded(plan, typed, ctx, opts = {}) {
|
|
396
|
+
const logged = (await readAddedTasks(ctx, opts)).map((t) => t.taskId);
|
|
397
|
+
const taskId = resolveTaskId(plan, typed, logged);
|
|
398
|
+
return appendEvent(envelope(planScoped(ctx, plan), 'task-superseded', { epic_id: plan.epic_id }, taskId), opts);
|
|
399
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The one place that knows what a task id is shaped like (D-49/P9-10).
|
|
2
|
+
//
|
|
3
|
+
// Task ids are "<epic>/<bare>" — plan.ts mints them, worktree.ts branches on
|
|
4
|
+
// them, findings.ts and the projector group by them. Four call sites derived
|
|
5
|
+
// the epic with `taskId.split('/')[0]`, and that expression has no failure
|
|
6
|
+
// mode: given an unqualified "task-0" it returns "task-0" and the caller
|
|
7
|
+
// reads it as an epic named "task-0". Nothing throws, nothing logs, and the
|
|
8
|
+
// comparison against the real epic quietly misses — the dogfood epic showed
|
|
9
|
+
// up in `smith epic verdict` as "has no tasks in the event log" while three
|
|
10
|
+
// tasks sat in it.
|
|
11
|
+
//
|
|
12
|
+
// So the derivation lives here once, and its unqualified answer is `null`,
|
|
13
|
+
// not a plausible lie. A caller that cannot proceed without an epic asks
|
|
14
|
+
// requireEpicOfTaskId() and gets a typed error naming the id.
|
|
15
|
+
import { SmithError } from './errors.js';
|
|
16
|
+
export class TaskIdError extends SmithError {
|
|
17
|
+
}
|
|
18
|
+
const SEPARATOR = '/';
|
|
19
|
+
/**
|
|
20
|
+
* Split a task id into its epic and bare halves, or null when it has no
|
|
21
|
+
* usable epic segment. Both halves must be non-empty: "/task-2" and "epic-1/"
|
|
22
|
+
* are malformed, not qualified.
|
|
23
|
+
*
|
|
24
|
+
* The bare half keeps every remaining separator ("epic-1/followup-ab/extra"
|
|
25
|
+
* -> epic "epic-1", bare "followup-ab/extra"): the epic is the FIRST segment
|
|
26
|
+
* by definition, and re-joining epic + bare must round-trip the input.
|
|
27
|
+
*
|
|
28
|
+
* D-135: the `string` in the signature is a claim, not a guarantee. Every id
|
|
29
|
+
* that reaches these helpers has been through a JSONL round-trip, where a
|
|
30
|
+
* field the writer never set arrives as `undefined` and TypeScript is no
|
|
31
|
+
* longer watching. `undefined.indexOf` threw a bare TypeError with no code
|
|
32
|
+
* and no id in it, from inside a reader three call frames from the bad
|
|
33
|
+
* record. A non-string names no epic, which is exactly what `null` says.
|
|
34
|
+
*/
|
|
35
|
+
function split(taskId) {
|
|
36
|
+
if (typeof taskId !== 'string')
|
|
37
|
+
return null;
|
|
38
|
+
const index = taskId.indexOf(SEPARATOR);
|
|
39
|
+
if (index <= 0)
|
|
40
|
+
return null;
|
|
41
|
+
const epicId = taskId.slice(0, index);
|
|
42
|
+
const bare = taskId.slice(index + 1);
|
|
43
|
+
return bare.length === 0 ? null : { epicId, bare };
|
|
44
|
+
}
|
|
45
|
+
/** The epic a task id names, or null when the id does not name one. */
|
|
46
|
+
export function epicOfTaskId(taskId) {
|
|
47
|
+
return split(taskId)?.epicId ?? null;
|
|
48
|
+
}
|
|
49
|
+
/** Whether `epicOfTaskId` can answer for this id. */
|
|
50
|
+
export function isQualifiedTaskId(taskId) {
|
|
51
|
+
return split(taskId) !== null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The id without its epic prefix; an unqualified id is already bare.
|
|
55
|
+
*
|
|
56
|
+
* A non-string answers `''` rather than falling through to `?? taskId`, which
|
|
57
|
+
* would hand the caller back a non-string through a `: string` signature —
|
|
58
|
+
* the same class of lie this module exists to stop. `''` is safe at every
|
|
59
|
+
* comparison site because no real task id is empty, so it can never make two
|
|
60
|
+
* distinct records look like the same task.
|
|
61
|
+
*/
|
|
62
|
+
export function bareTaskId(taskId) {
|
|
63
|
+
if (typeof taskId !== 'string')
|
|
64
|
+
return '';
|
|
65
|
+
return split(taskId)?.bare ?? taskId;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Whether two task ids name the same task, across both spellings.
|
|
69
|
+
*
|
|
70
|
+
* D-130. `task_id` is written both qualified ("<epic>/<task>") and bare
|
|
71
|
+
* ("<task>") for the same task — D-46/P9-10 fixed the producers, but the log
|
|
72
|
+
* is append-only, so every session opened before that fix carries both, and
|
|
73
|
+
* they interleave rather than splitting cleanly at a cutover point. A raw
|
|
74
|
+
* `!==` therefore answered a query by the canonical id with a well-formed
|
|
75
|
+
* SUBSET of the task's history, and a short answer is indistinguishable from
|
|
76
|
+
* a complete one unless the caller already knows the true count.
|
|
77
|
+
*
|
|
78
|
+
* Two qualified ids are still compared whole: "epic-a/task-2" and
|
|
79
|
+
* "epic-b/task-2" are different tasks that happen to share a bare name, and
|
|
80
|
+
* folding them together would trade a silent omission for a silent merge.
|
|
81
|
+
* Only when at least one side is bare — where the epic is genuinely unknown
|
|
82
|
+
* rather than different — do the bare halves decide it.
|
|
83
|
+
*
|
|
84
|
+
* D-143 moved the rule here from `events.ts`, where it was private and only
|
|
85
|
+
* `filterEvents` could reach it while the findings fold went on comparing
|
|
86
|
+
* raw. The rule is about what a task id *means*, so it belongs to the module
|
|
87
|
+
* that owns that question — the same reason `epicOfTaskId` lives here rather
|
|
88
|
+
* than at its four original call sites.
|
|
89
|
+
*/
|
|
90
|
+
export function taskIdsMatch(a, b) {
|
|
91
|
+
if (typeof a !== 'string' || typeof b !== 'string' || a === '' || b === '')
|
|
92
|
+
return false;
|
|
93
|
+
if (a === b)
|
|
94
|
+
return true;
|
|
95
|
+
if (isQualifiedTaskId(a) && isQualifiedTaskId(b))
|
|
96
|
+
return false;
|
|
97
|
+
return bareTaskId(a) === bareTaskId(b);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* `<epicId>/<taskId>`, idempotent when the id already carries that epic.
|
|
101
|
+
*
|
|
102
|
+
* Throws rather than double-prefixing when the id is qualified under a
|
|
103
|
+
* different epic: `smith/epic-2/epic-1/task-2` is not a branch name anyone
|
|
104
|
+
* meant, and the mismatch is a real disagreement between two producers about
|
|
105
|
+
* which epic owns the task — worth an error, not a silent pick.
|
|
106
|
+
*/
|
|
107
|
+
export function qualifyTaskId(epicId, taskId) {
|
|
108
|
+
const parts = split(taskId);
|
|
109
|
+
if (parts === null)
|
|
110
|
+
return `${epicId}${SEPARATOR}${taskId}`;
|
|
111
|
+
if (parts.epicId === epicId)
|
|
112
|
+
return taskId;
|
|
113
|
+
throw new TaskIdError('task-id.epic-mismatch', `Task id "${taskId}" is already qualified under epic "${parts.epicId}"; cannot re-qualify it under "${epicId}".`, { task_id: taskId, epic_id: epicId, qualified_under: parts.epicId });
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The epic, or a typed error. For call sites where an epic is structurally
|
|
117
|
+
* required — a follow-up task id, a branch name — and inventing one would
|
|
118
|
+
* produce a valid-looking artifact pointed at nothing.
|
|
119
|
+
*/
|
|
120
|
+
export function requireEpicOfTaskId(taskId) {
|
|
121
|
+
const epicId = epicOfTaskId(taskId);
|
|
122
|
+
if (epicId === null) {
|
|
123
|
+
throw new TaskIdError('task-id.unqualified', `Task id "${taskId}" carries no epic. Task ids are "<epic>/<task>" (plan.ts/worktree.ts); an epic cannot be derived from this one.`, { task_id: taskId });
|
|
124
|
+
}
|
|
125
|
+
return epicId;
|
|
126
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
3
|
+
import { SmithError } from './errors.js';
|
|
4
|
+
import { TAXONOMY_PATH } from './paths.js';
|
|
5
|
+
export class TaxonomyError extends SmithError {
|
|
6
|
+
}
|
|
7
|
+
const NON_DIMENSION_KEYS = new Set(['version', 'rules', 'error']);
|
|
8
|
+
// Fields required on a record that are references/collections, not taxonomy
|
|
9
|
+
// tags themselves — presence (non-empty) is enough, no dimension lookup.
|
|
10
|
+
// `model` (P9-23) is required but deliberately open: model ids turn over
|
|
11
|
+
// monthly, and a closed dimension would either reject next month's model or
|
|
12
|
+
// — the likelier outcome — get the field quietly dropped from dispatches
|
|
13
|
+
// instead of the taxonomy being bumped. Presence is what the asymmetry
|
|
14
|
+
// check needs; the exact value is only ever compared against another value.
|
|
15
|
+
const PRESENCE_ONLY_FIELDS = new Set(['task_ref', 'provenance_event_ids', 'model']);
|
|
16
|
+
/** The `error` dimension is validated as a `group.class` pair, not a flat tag. */
|
|
17
|
+
const ERROR_CLASS_FIELD = 'error';
|
|
18
|
+
export function parseTaxonomy(yamlText) {
|
|
19
|
+
const doc = parseYaml(yamlText);
|
|
20
|
+
if (!doc || typeof doc !== 'object') {
|
|
21
|
+
throw new TaxonomyError('taxonomy.invalid-document', 'Taxonomy YAML did not parse to a mapping.');
|
|
22
|
+
}
|
|
23
|
+
const version = doc.version;
|
|
24
|
+
if (typeof version !== 'number') {
|
|
25
|
+
throw new TaxonomyError('taxonomy.invalid-document', 'Taxonomy is missing a numeric `version`.');
|
|
26
|
+
}
|
|
27
|
+
const errorNode = doc.error;
|
|
28
|
+
if (!errorNode || typeof errorNode !== 'object') {
|
|
29
|
+
throw new TaxonomyError('taxonomy.invalid-document', 'Taxonomy is missing the `error` group block.');
|
|
30
|
+
}
|
|
31
|
+
const errorGroups = {};
|
|
32
|
+
for (const [group, classes] of Object.entries(errorNode)) {
|
|
33
|
+
if (!Array.isArray(classes)) {
|
|
34
|
+
throw new TaxonomyError('taxonomy.invalid-document', `error group "${group}" must be an array of classes.`);
|
|
35
|
+
}
|
|
36
|
+
errorGroups[group] = classes.map(String);
|
|
37
|
+
}
|
|
38
|
+
const dimensions = {};
|
|
39
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
40
|
+
if (NON_DIMENSION_KEYS.has(key))
|
|
41
|
+
continue;
|
|
42
|
+
if (!Array.isArray(value))
|
|
43
|
+
continue;
|
|
44
|
+
dimensions[key] = value.map(String);
|
|
45
|
+
}
|
|
46
|
+
const rulesNode = doc.rules;
|
|
47
|
+
const requiredDimensions = rulesNode?.required_dimensions ?? {};
|
|
48
|
+
if (Object.keys(requiredDimensions).length === 0) {
|
|
49
|
+
throw new TaxonomyError('taxonomy.invalid-document', 'Taxonomy is missing rules.required_dimensions.');
|
|
50
|
+
}
|
|
51
|
+
return { version, dimensions, errorGroups, requiredDimensions };
|
|
52
|
+
}
|
|
53
|
+
export function loadTaxonomy(filePath = TAXONOMY_PATH) {
|
|
54
|
+
const text = readFileSync(filePath, 'utf8');
|
|
55
|
+
return parseTaxonomy(text);
|
|
56
|
+
}
|
|
57
|
+
/** Validate a single tag value against one taxonomy dimension. Never silently passes. */
|
|
58
|
+
export function validateTag(taxonomy, dimension, value) {
|
|
59
|
+
const allowed = taxonomy.dimensions[dimension];
|
|
60
|
+
if (!allowed) {
|
|
61
|
+
throw new TaxonomyError('taxonomy.unknown-dimension', `Unknown taxonomy dimension "${dimension}".`, { dimension });
|
|
62
|
+
}
|
|
63
|
+
if (!allowed.includes(value)) {
|
|
64
|
+
throw new TaxonomyError('taxonomy.unknown-tag', `Unknown value "${value}" for taxonomy dimension "${dimension}".`, { dimension, value, allowed });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Validate a grouped error class, always logged/referenced as `group.class`. */
|
|
68
|
+
export function validateErrorClass(taxonomy, value) {
|
|
69
|
+
const parts = value.split('.');
|
|
70
|
+
if (parts.length !== 2 || parts[0] === '' || parts[1] === '') {
|
|
71
|
+
throw new TaxonomyError('taxonomy.invalid-error-class-format', `Error class "${value}" must be in "group.class" form.`, { value });
|
|
72
|
+
}
|
|
73
|
+
const [group, cls] = parts;
|
|
74
|
+
const classes = taxonomy.errorGroups[group];
|
|
75
|
+
if (!classes) {
|
|
76
|
+
throw new TaxonomyError('taxonomy.unknown-error-group', `Unknown error group "${group}".`, {
|
|
77
|
+
group,
|
|
78
|
+
value,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (!classes.includes(cls)) {
|
|
82
|
+
throw new TaxonomyError('taxonomy.unknown-error-class', `Unknown error class "${cls}" in group "${group}".`, { group, class: cls, value, allowed: classes });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Enforce the required-dimensions-per-record-type rules (taxonomy.yml `rules:`
|
|
87
|
+
* block). Checks presence of every required field, then validates each
|
|
88
|
+
* field's value against its taxonomy dimension (or the group.class rule for
|
|
89
|
+
* the `error` field, or mere non-empty presence for reference-only fields).
|
|
90
|
+
*/
|
|
91
|
+
export function validateRequiredDimensions(taxonomy, recordType, record) {
|
|
92
|
+
const required = taxonomy.requiredDimensions[recordType];
|
|
93
|
+
if (!required) {
|
|
94
|
+
throw new TaxonomyError('taxonomy.unknown-record-type', `Unknown record type "${recordType}" — no required-dimensions rule for it.`, { recordType });
|
|
95
|
+
}
|
|
96
|
+
for (const field of required) {
|
|
97
|
+
const value = record[field];
|
|
98
|
+
const isEmpty = value === undefined ||
|
|
99
|
+
value === null ||
|
|
100
|
+
value === '' ||
|
|
101
|
+
(Array.isArray(value) && value.length === 0);
|
|
102
|
+
if (isEmpty) {
|
|
103
|
+
throw new TaxonomyError('taxonomy.missing-required-dimension', `Record type "${recordType}" is missing required field "${field}".`, { recordType, field });
|
|
104
|
+
}
|
|
105
|
+
if (PRESENCE_ONLY_FIELDS.has(field))
|
|
106
|
+
continue;
|
|
107
|
+
if (field === ERROR_CLASS_FIELD) {
|
|
108
|
+
validateErrorClass(taxonomy, String(value));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
validateTag(taxonomy, field, String(value));
|
|
112
|
+
}
|
|
113
|
+
}
|