@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,408 @@
|
|
|
1
|
+
// Deterministic scheduler pass (architecture §12 "Recheck scheduling" +
|
|
2
|
+
// "Scheduled growth passes"; `smith scheduler run`). Reads the event log
|
|
3
|
+
// (via db/projector.ts's already-owned task fold — no reason to re-derive
|
|
4
|
+
// task-lifecycle state a second time) and proposes work; it NEVER dispatches
|
|
5
|
+
// an agent and never widens scope itself — every output here is a
|
|
6
|
+
// `*-proposed`/`growth-review-due` event for a human or a later planner
|
|
7
|
+
// session to act on.
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { parse as parseYaml } from 'yaml';
|
|
12
|
+
import { globsOverlap } from './claims.js';
|
|
13
|
+
import { foldTasks, taskIdCanonicalizer } from './db/projector.js';
|
|
14
|
+
import { SmithError } from './errors.js';
|
|
15
|
+
import { appendEvent } from './events.js';
|
|
16
|
+
import { SCHEDULER_POLICY_PATH } from './paths.js';
|
|
17
|
+
export class SchedulerError extends SmithError {
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Every knob, checked rather than trusted (D-203 for the two `lessons` ones).
|
|
21
|
+
*
|
|
22
|
+
* `??` defaults on null/undefined only, so a YAML typo -- `days_elapsed:
|
|
23
|
+
* fourteen`, `cadence_days: monthly` -- arrived at the comparison as itself,
|
|
24
|
+
* with the declared `number` type saying otherwise. Nothing then failed. The
|
|
25
|
+
* comparison simply answered differently, in a different direction at each
|
|
26
|
+
* knob it reaches, which is the same defect cli.ts already reasoned out for a
|
|
27
|
+
* mistyped `--now` (D-209):
|
|
28
|
+
*
|
|
29
|
+
* - `999 >= 'fourteen'` is false, so a task untouched for three years never
|
|
30
|
+
* proposes `time-elapsed`. Fails CLOSED: the operator asks what is due and
|
|
31
|
+
* the typo answers "nothing".
|
|
32
|
+
* - `0.001 < 'monthly'` is false, so `proposeGrowthReview` never returns
|
|
33
|
+
* null and the review fires on every pass. Fails OPEN, from the same typo.
|
|
34
|
+
* - `shingle_size: 0` makes `shingles()` emit one empty gram per statement,
|
|
35
|
+
* so any two score a Jaccard of 1.0 and `smith dream` auto-rejects every
|
|
36
|
+
* checkpoint it extracts.
|
|
37
|
+
* - a non-numeric novelty threshold makes `score >= threshold` a NaN
|
|
38
|
+
* comparison, always false, so nothing is ever redundant.
|
|
39
|
+
*
|
|
40
|
+
* A wrong *number* still passes, deliberately: `days_elapsed: 1` proposes more
|
|
41
|
+
* rechecks and the operator reads them, so it argues with itself in the open.
|
|
42
|
+
* A wrong *type* argues in silence. Finiteness is part of the type check, not
|
|
43
|
+
* a range rule -- `.inf` is a number that no count can ever reach.
|
|
44
|
+
*
|
|
45
|
+
* The refusal names the value because a policy file is hand-edited.
|
|
46
|
+
*/
|
|
47
|
+
const READS_SILENTLY = 'A value the comparison cannot read does not fail the run, it changes what the run proposes.';
|
|
48
|
+
const VOIDS_THE_GATE = 'The novelty gate reads this directly and a degenerate value voids it silently.';
|
|
49
|
+
const TRUTHY_IS_NOT_TRUE = 'YAML 1.2 reads `on`/`off`/`yes`/`no` as strings, and every non-empty string is truthy — an operator who typed `off` would have switched the gate ON.';
|
|
50
|
+
function checkKnob(field, value, expected, note, ok = () => true) {
|
|
51
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || !ok(value)) {
|
|
52
|
+
throw new SchedulerError('scheduler.invalid-policy', `scheduler.yml ${field} must be ${expected}; got ${JSON.stringify(value)}. ${note}`, { field, value });
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
/** A knob whose only rule is that a comparison can read it. */
|
|
57
|
+
function count(field, value) {
|
|
58
|
+
return checkKnob(field, value, 'a finite number', READS_SILENTLY);
|
|
59
|
+
}
|
|
60
|
+
/** A knob the code branches on directly, where any string at all would read as `true`. */
|
|
61
|
+
function flag(field, value, note) {
|
|
62
|
+
if (typeof value !== 'boolean') {
|
|
63
|
+
throw new SchedulerError('scheduler.invalid-policy', `scheduler.yml ${field} must be true or false; got ${JSON.stringify(value)}. ${note}`, { field, value });
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The closed vocabularies the `autonomy:` whitelists draw from. Closed on
|
|
69
|
+
* purpose: a name that matches nothing would fail closed, which is the safe
|
|
70
|
+
* direction and exactly why it has to be loud — `maintenence` would leave
|
|
71
|
+
* autonomy switched on and admitting nothing, and the log would say only that
|
|
72
|
+
* the operator was never asked.
|
|
73
|
+
*/
|
|
74
|
+
const PROPOSAL_KINDS = [
|
|
75
|
+
'recheck',
|
|
76
|
+
'maintenance',
|
|
77
|
+
'growth-review-due',
|
|
78
|
+
];
|
|
79
|
+
const RECHECK_REASONS = [
|
|
80
|
+
'merge-threshold',
|
|
81
|
+
'time-elapsed',
|
|
82
|
+
'low-confidence',
|
|
83
|
+
];
|
|
84
|
+
const A_LIST_IS_NOT_A_STRING = 'A bare scalar spreads into its characters here, so `recheck` would read as seven kinds that match nothing.';
|
|
85
|
+
/** A whitelist: a list of strings, every one of them a name this code knows. */
|
|
86
|
+
function vocabList(field, value, vocabulary) {
|
|
87
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
88
|
+
throw new SchedulerError('scheduler.invalid-policy', `scheduler.yml ${field} must be a list of strings; got ${JSON.stringify(value)}. ${A_LIST_IS_NOT_A_STRING}`, { field, value });
|
|
89
|
+
}
|
|
90
|
+
const entries = value;
|
|
91
|
+
const unknown = entries.filter((entry) => !vocabulary.includes(entry));
|
|
92
|
+
if (unknown.length > 0) {
|
|
93
|
+
throw new SchedulerError('scheduler.invalid-policy', `scheduler.yml ${field} lists ${unknown.map((u) => JSON.stringify(u)).join(', ')}, which is not one of: ${vocabulary.join(', ')}. A name nothing matches disables silently.`, { field, value: unknown });
|
|
94
|
+
}
|
|
95
|
+
return [...entries];
|
|
96
|
+
}
|
|
97
|
+
export function parseSchedulerPolicy(yamlText) {
|
|
98
|
+
const doc = (parseYaml(yamlText) ?? {});
|
|
99
|
+
if (!doc.recheck || !doc.maintenance || !doc.growth) {
|
|
100
|
+
throw new SchedulerError('scheduler.invalid-policy', 'scheduler.yml is missing one of recheck/maintenance/growth.');
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
recheck: {
|
|
104
|
+
mergeThreshold: count('recheck.merge_threshold', doc.recheck.merge_threshold ?? 5),
|
|
105
|
+
daysElapsed: count('recheck.days_elapsed', doc.recheck.days_elapsed ?? 14),
|
|
106
|
+
confidenceThreshold: count('recheck.confidence_threshold', doc.recheck.confidence_threshold ?? 0.6),
|
|
107
|
+
},
|
|
108
|
+
maintenance: {
|
|
109
|
+
autoScheduleConfidence: count('maintenance.auto_schedule_confidence', doc.maintenance.auto_schedule_confidence ?? 0.8),
|
|
110
|
+
majorBumpConfidence: count('maintenance.major_bump_confidence', doc.maintenance.major_bump_confidence ?? 0.5),
|
|
111
|
+
minorOrPatchConfidence: count('maintenance.minor_or_patch_confidence', doc.maintenance.minor_or_patch_confidence ?? 0.9),
|
|
112
|
+
},
|
|
113
|
+
growth: { cadenceDays: count('growth.cadence_days', doc.growth.cadence_days ?? 30) },
|
|
114
|
+
// Every default fails closed. A scheduler.yml written before autonomy
|
|
115
|
+
// existed, or one a downstream project trimmed, must not inherit this
|
|
116
|
+
// repo's answer to "what may run without me": absent means off, and off
|
|
117
|
+
// means an empty whitelist behind it.
|
|
118
|
+
autonomy: {
|
|
119
|
+
enabled: flag('autonomy.enabled', doc.autonomy?.enabled ?? false, TRUTHY_IS_NOT_TRUE),
|
|
120
|
+
autoDispatchKinds: vocabList('autonomy.auto_dispatch_kinds', doc.autonomy?.auto_dispatch_kinds ?? [], PROPOSAL_KINDS),
|
|
121
|
+
autoDispatchRecheckReasons: vocabList('autonomy.auto_dispatch_recheck_reasons', doc.autonomy?.auto_dispatch_recheck_reasons ?? [], RECHECK_REASONS),
|
|
122
|
+
confidenceFloor: checkKnob('autonomy.confidence_floor', doc.autonomy?.confidence_floor ?? 0.8, 'a number in [0, 1]', READS_SILENTLY, (n) => n >= 0 && n <= 1),
|
|
123
|
+
},
|
|
124
|
+
lessons: {
|
|
125
|
+
noveltyJaccardThreshold: checkKnob('lessons.novelty_jaccard_threshold', doc.lessons?.novelty_jaccard_threshold ?? 0.8, 'a number in (0, 1]', VOIDS_THE_GATE, (n) => n > 0 && n <= 1),
|
|
126
|
+
shingleSize: checkKnob('lessons.shingle_size', doc.lessons?.shingle_size ?? 3, 'an integer >= 1', VOIDS_THE_GATE, (n) => Number.isInteger(n) && n >= 1),
|
|
127
|
+
noveltyLengthAware: flag('lessons.novelty_length_aware', doc.lessons?.novelty_length_aware ?? true, TRUTHY_IS_NOT_TRUE),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function loadSchedulerPolicy(filePath = SCHEDULER_POLICY_PATH) {
|
|
132
|
+
return parseSchedulerPolicy(readFileSync(filePath, 'utf8'));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* task_id -> confidence from its task-result-recorded event, default 1 (full
|
|
136
|
+
* confidence) when absent.
|
|
137
|
+
*
|
|
138
|
+
* Keyed by the canonical id, because `smith gate run <taskId>` stamps whatever
|
|
139
|
+
* spelling the operator typed: the same task reaches the log qualified from
|
|
140
|
+
* `task-added` and bare from its result. `proposeRechecks` reads this map with
|
|
141
|
+
* the canonical id `foldTasks` folded the row under, so a raw key would miss,
|
|
142
|
+
* fall back to full confidence, and make `low-confidence` unfireable (D-182).
|
|
143
|
+
*/
|
|
144
|
+
function confidenceByTask(events) {
|
|
145
|
+
const byTask = new Map();
|
|
146
|
+
const canonical = taskIdCanonicalizer(events);
|
|
147
|
+
for (const { record } of events) {
|
|
148
|
+
if (record.event_type !== 'task-result-recorded')
|
|
149
|
+
continue;
|
|
150
|
+
const p = record.payload;
|
|
151
|
+
const taskId = record.task_id ?? p.task_id;
|
|
152
|
+
const confidence = p.structured_output?.confidence;
|
|
153
|
+
if (taskId !== undefined && typeof confidence === 'number') {
|
|
154
|
+
byTask.set(canonical(taskId), confidence);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return byTask;
|
|
158
|
+
}
|
|
159
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
160
|
+
/**
|
|
161
|
+
* `taskId` -> the event-array index of its MOST RECENT `recheck-proposed`
|
|
162
|
+
* event (this scheduler's own prior output — `runScheduler()` appends this
|
|
163
|
+
* event type verbatim from `RecheckProposal`, which uses `taskId`, camelCase
|
|
164
|
+
* — a deliberate deviation from this file's `task_id` snake_case elsewhere,
|
|
165
|
+
* since the payload here isn't a required-taxonomy-dimension record; noted,
|
|
166
|
+
* not renamed, to avoid an unrelated payload-shape change in this fix).
|
|
167
|
+
*/
|
|
168
|
+
function latestRecheckProposalIndex(events) {
|
|
169
|
+
const latest = new Map();
|
|
170
|
+
events.forEach(({ record }, index) => {
|
|
171
|
+
if (record.event_type !== 'recheck-proposed')
|
|
172
|
+
return;
|
|
173
|
+
const p = record.payload;
|
|
174
|
+
if (p.taskId)
|
|
175
|
+
latest.set(p.taskId, index);
|
|
176
|
+
});
|
|
177
|
+
return latest;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A prior `recheck-proposed` for `taskId` counts as RESOLVED once one of two
|
|
181
|
+
* events for it appears LATER in the log:
|
|
182
|
+
* (a) `task-added` whose payload.recheck_of === taskId — a real recheck
|
|
183
|
+
* task was created for it (the convention `/bs plan`'s playbook
|
|
184
|
+
* follows when it turns a proposal into a task), or
|
|
185
|
+
* (b) `recheck-declined` whose payload.task_id === taskId — the operator
|
|
186
|
+
* explicitly said no.
|
|
187
|
+
* Deliberately NARROW: no other event touching taskId resolves a pending
|
|
188
|
+
* proposal. Idempotency must fail closed — re-proposing the same recheck is
|
|
189
|
+
* harmless noise the operator/planner can ignore; silently losing a real,
|
|
190
|
+
* still-open recheck need because some unrelated event happened to
|
|
191
|
+
* reference the task would not be.
|
|
192
|
+
*/
|
|
193
|
+
function isRecheckResolved(taskId, eventsAfterProposal) {
|
|
194
|
+
for (const { record } of eventsAfterProposal) {
|
|
195
|
+
if (record.event_type === 'task-added') {
|
|
196
|
+
const p = record.payload;
|
|
197
|
+
if (p.recheck_of === taskId)
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
else if (record.event_type === 'recheck-declined') {
|
|
201
|
+
const p = record.payload;
|
|
202
|
+
if (p.task_id === taskId)
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* §12 recheck policy over already-completed features: for each completed,
|
|
210
|
+
* non-recheck-origin task, count LATER-completed tasks whose claims overlap
|
|
211
|
+
* it (mergeCount), days elapsed since it completed, and its completion
|
|
212
|
+
* confidence (from task-result-recorded, default 1). Proposes a recheck when
|
|
213
|
+
* any one of the three thresholds trips; `reasons` lists every one that did
|
|
214
|
+
* — UNLESS an earlier `recheck-proposed` for the same task is still
|
|
215
|
+
* unresolved (isRecheckResolved), in which case it is skipped: re-running
|
|
216
|
+
* this pass must never emit a duplicate proposal for the same open recheck.
|
|
217
|
+
*/
|
|
218
|
+
export function proposeRechecks(events, now, policy) {
|
|
219
|
+
const tasks = foldTasks(events);
|
|
220
|
+
const completed = tasks.filter((t) => t.taskStatus === 'completed' && t.origin !== 'recheck');
|
|
221
|
+
const confidence = confidenceByTask(events);
|
|
222
|
+
const latestProposalIndex = latestRecheckProposalIndex(events);
|
|
223
|
+
const proposals = [];
|
|
224
|
+
for (const task of completed) {
|
|
225
|
+
const mergeCount = countLaterOverlappingMerges(task, tasks);
|
|
226
|
+
const daysElapsed = (now.getTime() - Date.parse(task.updatedAt)) / MS_PER_DAY;
|
|
227
|
+
const taskConfidence = confidence.get(task.taskId) ?? 1;
|
|
228
|
+
const reasons = [];
|
|
229
|
+
if (mergeCount >= policy.mergeThreshold)
|
|
230
|
+
reasons.push('merge-threshold');
|
|
231
|
+
if (daysElapsed >= policy.daysElapsed)
|
|
232
|
+
reasons.push('time-elapsed');
|
|
233
|
+
if (taskConfidence < policy.confidenceThreshold)
|
|
234
|
+
reasons.push('low-confidence');
|
|
235
|
+
if (reasons.length === 0)
|
|
236
|
+
continue;
|
|
237
|
+
const priorIndex = latestProposalIndex.get(task.taskId);
|
|
238
|
+
if (priorIndex !== undefined) {
|
|
239
|
+
const eventsAfter = events.slice(priorIndex + 1);
|
|
240
|
+
if (!isRecheckResolved(task.taskId, eventsAfter))
|
|
241
|
+
continue; // still pending -> skip, idempotent
|
|
242
|
+
}
|
|
243
|
+
proposals.push({
|
|
244
|
+
kind: 'recheck',
|
|
245
|
+
taskId: task.taskId,
|
|
246
|
+
epicId: task.epicId,
|
|
247
|
+
reasons,
|
|
248
|
+
mergeCount,
|
|
249
|
+
daysElapsed: Math.floor(daysElapsed),
|
|
250
|
+
confidence: taskConfidence,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return proposals.sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
254
|
+
}
|
|
255
|
+
function countLaterOverlappingMerges(task, allTasks) {
|
|
256
|
+
if (!task.claims || task.claims.length === 0)
|
|
257
|
+
return 0;
|
|
258
|
+
let count = 0;
|
|
259
|
+
for (const other of allTasks) {
|
|
260
|
+
if (other.taskId === task.taskId)
|
|
261
|
+
continue;
|
|
262
|
+
if (other.taskStatus !== 'completed')
|
|
263
|
+
continue;
|
|
264
|
+
if (Date.parse(other.updatedAt) <= Date.parse(task.updatedAt))
|
|
265
|
+
continue;
|
|
266
|
+
if (!other.claims || other.claims.length === 0)
|
|
267
|
+
continue;
|
|
268
|
+
const overlaps = task.claims.some((a) => other.claims?.some((b) => globsOverlap(a, b)));
|
|
269
|
+
if (overlaps)
|
|
270
|
+
count++;
|
|
271
|
+
}
|
|
272
|
+
return count;
|
|
273
|
+
}
|
|
274
|
+
export function parsePnpmOutdated(json) {
|
|
275
|
+
const doc = JSON.parse(json);
|
|
276
|
+
return Object.entries(doc)
|
|
277
|
+
.filter(([, v]) => v.current && v.wanted && v.latest)
|
|
278
|
+
.map(([name, v]) => ({
|
|
279
|
+
name,
|
|
280
|
+
current: v.current,
|
|
281
|
+
wanted: v.wanted,
|
|
282
|
+
latest: v.latest,
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
/** Best-effort `pnpm outdated --json` in `projectDir`; null when pnpm/lockfile is unavailable ("when available"). */
|
|
286
|
+
export function runPnpmOutdated(projectDir) {
|
|
287
|
+
let stdout;
|
|
288
|
+
try {
|
|
289
|
+
// pnpm outdated exits 1 when it finds outdated packages — the JSON is
|
|
290
|
+
// still on stdout, so a non-zero exit is read from the error, not treated
|
|
291
|
+
// as failure. A genuinely missing pnpm/lockfile throws with no stdout.
|
|
292
|
+
// stdio explicit for the same reason as git.ts: without it execFileSync
|
|
293
|
+
// sends pnpm's stderr to the operator's terminal, and "No dependencies
|
|
294
|
+
// found" on a fresh project reads as a factory failure (P9-16b).
|
|
295
|
+
stdout = execFileSync('pnpm', ['outdated', '--json'], {
|
|
296
|
+
cwd: projectDir,
|
|
297
|
+
encoding: 'utf8',
|
|
298
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
const withStdout = err;
|
|
303
|
+
if (!withStdout.stdout)
|
|
304
|
+
return null;
|
|
305
|
+
stdout = withStdout.stdout;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
return parsePnpmOutdated(stdout);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return null; // not valid JSON — nothing to propose, not a crash
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function majorVersion(semver) {
|
|
315
|
+
const match = semver.replace(/^[^0-9]*/, '').match(/^(\d+)/);
|
|
316
|
+
return match ? Number.parseInt(match[1], 10) : 0;
|
|
317
|
+
}
|
|
318
|
+
/** origin: inferred (agent-constraints.md "planner / Growth passes") — auto-schedulable only at high confidence. */
|
|
319
|
+
export function proposeMaintenance(projectDir, packages, policy) {
|
|
320
|
+
if (packages.length === 0)
|
|
321
|
+
return null;
|
|
322
|
+
const hasMajorBump = packages.some((p) => majorVersion(p.latest) > majorVersion(p.current));
|
|
323
|
+
const confidence = hasMajorBump ? policy.majorBumpConfidence : policy.minorOrPatchConfidence;
|
|
324
|
+
return {
|
|
325
|
+
kind: 'maintenance',
|
|
326
|
+
projectDir: path.resolve(projectDir),
|
|
327
|
+
packages: [...packages],
|
|
328
|
+
confidence,
|
|
329
|
+
autoSchedulable: confidence >= policy.autoScheduleConfidence,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Emits the TRIGGER only — architecture §12: "product-growth proposals
|
|
334
|
+
* always wait for an operator tick"; the planner session (not this
|
|
335
|
+
* scheduler) reads the living spec/analytics and proposes scope. Fires once
|
|
336
|
+
* cadenceDays have elapsed since the last growth-review-due event in this
|
|
337
|
+
* log (or immediately, if none has ever fired).
|
|
338
|
+
*/
|
|
339
|
+
export function proposeGrowthReview(events, now, policy) {
|
|
340
|
+
let lastReviewAt = null;
|
|
341
|
+
for (const { record } of events) {
|
|
342
|
+
if (record.event_type === 'growth-review-due')
|
|
343
|
+
lastReviewAt = record.ts;
|
|
344
|
+
}
|
|
345
|
+
if (lastReviewAt === null) {
|
|
346
|
+
return { kind: 'growth-review-due', cadenceDays: policy.cadenceDays, lastReviewAt: null };
|
|
347
|
+
}
|
|
348
|
+
const daysSince = (now.getTime() - Date.parse(lastReviewAt)) / MS_PER_DAY;
|
|
349
|
+
if (daysSince < policy.cadenceDays)
|
|
350
|
+
return null;
|
|
351
|
+
return { kind: 'growth-review-due', cadenceDays: policy.cadenceDays, lastReviewAt };
|
|
352
|
+
}
|
|
353
|
+
/** Pure: computes every proposal this pass would make, without touching the event log. */
|
|
354
|
+
export function computeProposals(input) {
|
|
355
|
+
const policy = input.policy ?? loadSchedulerPolicy();
|
|
356
|
+
const now = input.now ?? new Date();
|
|
357
|
+
const readOutdated = input.readOutdated ?? runPnpmOutdated;
|
|
358
|
+
const proposals = [...proposeRechecks(input.events, now, policy.recheck)];
|
|
359
|
+
// In the order the repos were given, and one repo's silence costs only that
|
|
360
|
+
// repo: "when available" is a property of each lockfile, so a child project
|
|
361
|
+
// that has never been installed must not take the reading on all the others
|
|
362
|
+
// down with it.
|
|
363
|
+
for (const projectDir of input.projectDirs ?? []) {
|
|
364
|
+
const outdated = readOutdated(projectDir);
|
|
365
|
+
if (!outdated)
|
|
366
|
+
continue;
|
|
367
|
+
const maintenance = proposeMaintenance(projectDir, outdated, policy.maintenance);
|
|
368
|
+
if (maintenance)
|
|
369
|
+
proposals.push(maintenance);
|
|
370
|
+
}
|
|
371
|
+
const growth = proposeGrowthReview(input.events, now, policy.growth);
|
|
372
|
+
if (growth)
|
|
373
|
+
proposals.push(growth);
|
|
374
|
+
return proposals;
|
|
375
|
+
}
|
|
376
|
+
/** event_type per proposal kind — free strings, same taxonomy precedent as dispatch_decision/gate-outcome. */
|
|
377
|
+
function eventTypeFor(proposal) {
|
|
378
|
+
if (proposal.kind === 'recheck')
|
|
379
|
+
return 'recheck-proposed';
|
|
380
|
+
if (proposal.kind === 'maintenance')
|
|
381
|
+
return 'maintenance-proposed';
|
|
382
|
+
return 'growth-review-due';
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Runs computeProposals() and, unless `dryRun`, appends one event per
|
|
386
|
+
* proposal (chained causal_parent) — the scheduler's only write. Never
|
|
387
|
+
* dispatches an agent itself (architecture §12).
|
|
388
|
+
*/
|
|
389
|
+
export async function runScheduler(input, ctx, opts = {}, dryRun = false) {
|
|
390
|
+
const proposals = computeProposals(input);
|
|
391
|
+
const eventIds = [];
|
|
392
|
+
if (!dryRun) {
|
|
393
|
+
let parent = ctx.causalParent;
|
|
394
|
+
for (const proposal of proposals) {
|
|
395
|
+
const event = await appendEvent({
|
|
396
|
+
session_id: ctx.sessionId,
|
|
397
|
+
actor: ctx.actor ?? 'system',
|
|
398
|
+
event_type: eventTypeFor(proposal),
|
|
399
|
+
plan_version: ctx.planVersion,
|
|
400
|
+
causal_parent: parent,
|
|
401
|
+
payload: proposal,
|
|
402
|
+
}, opts);
|
|
403
|
+
parent = event.event_id;
|
|
404
|
+
eventIds.push(event.event_id);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { proposals, eventIds };
|
|
408
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
4
|
+
import addFormatsImport from 'ajv-formats';
|
|
5
|
+
// ajv-formats ships a .d.ts written as an ES module (`export default`) but its
|
|
6
|
+
// package.json has no "type": "module", so under NodeNext resolution its
|
|
7
|
+
// declared type is the whole CJS module namespace, not the callable plugin
|
|
8
|
+
// function it actually is at runtime (verified: module.exports is the
|
|
9
|
+
// function itself). Narrow it back to the callable shape here, once.
|
|
10
|
+
const addFormats = addFormatsImport;
|
|
11
|
+
import { SmithError } from './errors.js';
|
|
12
|
+
import { SCHEMA_DIR } from './paths.js';
|
|
13
|
+
import { validateTag } from './taxonomy.js';
|
|
14
|
+
export class SchemaError extends SmithError {
|
|
15
|
+
}
|
|
16
|
+
function schemaNameFromFile(fileName) {
|
|
17
|
+
// "task-spec.schema.json" -> "task-spec"
|
|
18
|
+
return fileName.replace(/\.schema\.json$/, '');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Recursively collect every `x-taxonomy`-annotated field path in a JSON Schema.
|
|
22
|
+
* A `[]` segment stands for "every element of the array here" — see
|
|
23
|
+
* `resolveAtPath`, which is the half that has to know how to walk it.
|
|
24
|
+
*/
|
|
25
|
+
function collectTaxonomyPointers(node, segments = [], schemaPath = '') {
|
|
26
|
+
if (!node || typeof node !== 'object')
|
|
27
|
+
return [];
|
|
28
|
+
const found = [];
|
|
29
|
+
const obj = node;
|
|
30
|
+
const properties = obj.properties;
|
|
31
|
+
if (properties && typeof properties === 'object') {
|
|
32
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
33
|
+
const childSegments = [...segments, key];
|
|
34
|
+
const childPath = `${schemaPath}/properties/${key}`;
|
|
35
|
+
const prop = propSchema;
|
|
36
|
+
if (typeof prop['x-taxonomy'] === 'string') {
|
|
37
|
+
found.push({
|
|
38
|
+
segments: childSegments,
|
|
39
|
+
dimension: prop['x-taxonomy'],
|
|
40
|
+
schemaPath: childPath,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
found.push(...collectTaxonomyPointers(prop, childSegments, childPath));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const items = obj.items;
|
|
47
|
+
if (items && typeof items === 'object') {
|
|
48
|
+
const itemSegments = [...segments, '[]'];
|
|
49
|
+
const itemPath = `${schemaPath}/items`;
|
|
50
|
+
// An array of tag strings has no property to hang the annotation on, so
|
|
51
|
+
// it goes on `items` itself. Only entries of `properties` were read for
|
|
52
|
+
// one, which meant that whole shape was invisible (D-194).
|
|
53
|
+
const item = items;
|
|
54
|
+
if (typeof item['x-taxonomy'] === 'string') {
|
|
55
|
+
found.push({ segments: itemSegments, dimension: item['x-taxonomy'], schemaPath: itemPath });
|
|
56
|
+
}
|
|
57
|
+
found.push(...collectTaxonomyPointers(items, itemSegments, itemPath));
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Every `x-taxonomy` in the document, wherever it sits, as JSON pointers.
|
|
63
|
+
* Deliberately keyword-blind: it walks the raw JSON, so it sees the ones
|
|
64
|
+
* `collectTaxonomyPointers` walks past as well as the ones it collects. The
|
|
65
|
+
* difference between the two lists is the hole.
|
|
66
|
+
*/
|
|
67
|
+
function annotationPathsAnywhere(node, schemaPath = '') {
|
|
68
|
+
if (!node || typeof node !== 'object')
|
|
69
|
+
return [];
|
|
70
|
+
if (Array.isArray(node)) {
|
|
71
|
+
return node.flatMap((child, i) => annotationPathsAnywhere(child, `${schemaPath}/${i}`));
|
|
72
|
+
}
|
|
73
|
+
const found = [];
|
|
74
|
+
for (const [key, value] of Object.entries(node)) {
|
|
75
|
+
if (key === 'x-taxonomy' && typeof value === 'string') {
|
|
76
|
+
found.push(schemaPath);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
found.push(...annotationPathsAnywhere(value, `${schemaPath}/${key}`));
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Every value a pointer names. One for a plain field; one per element for a
|
|
85
|
+
* `[]` segment, which is why this returns a list rather than a value — a
|
|
86
|
+
* pointer under an array is a question about each element, and the answer has
|
|
87
|
+
* to say which one.
|
|
88
|
+
*/
|
|
89
|
+
function resolveAtPath(data, segments, prefix = []) {
|
|
90
|
+
const [segment, ...rest] = segments;
|
|
91
|
+
if (segment === undefined)
|
|
92
|
+
return [{ path: prefix, value: data }];
|
|
93
|
+
if (segment === '[]') {
|
|
94
|
+
if (!Array.isArray(data))
|
|
95
|
+
return [];
|
|
96
|
+
return data.flatMap((element, i) => resolveAtPath(element, rest, [...prefix, String(i)]));
|
|
97
|
+
}
|
|
98
|
+
if (data === null || data === undefined || typeof data !== 'object')
|
|
99
|
+
return [];
|
|
100
|
+
return resolveAtPath(data[segment], rest, [...prefix, segment]);
|
|
101
|
+
}
|
|
102
|
+
export function loadSchemaFiles(dir = SCHEMA_DIR) {
|
|
103
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.schema.json'));
|
|
104
|
+
const schemas = new Map();
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
const text = readFileSync(path.join(dir, file), 'utf8');
|
|
107
|
+
schemas.set(schemaNameFromFile(file), JSON.parse(text));
|
|
108
|
+
}
|
|
109
|
+
return schemas;
|
|
110
|
+
}
|
|
111
|
+
export function compileSchemas(_taxonomy, dir = SCHEMA_DIR) {
|
|
112
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
113
|
+
addFormats(ajv);
|
|
114
|
+
const raw = loadSchemaFiles(dir);
|
|
115
|
+
const compiled = new Map();
|
|
116
|
+
for (const [name, schema] of raw) {
|
|
117
|
+
const taxonomyPointers = collectTaxonomyPointers(schema);
|
|
118
|
+
assertEveryAnnotationReachable(name, schema, taxonomyPointers);
|
|
119
|
+
compiled.set(name, {
|
|
120
|
+
name,
|
|
121
|
+
validate: ajv.compile(schema),
|
|
122
|
+
taxonomyPointers,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return compiled;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Refuse a schema that annotates a field the pointer walker cannot reach.
|
|
129
|
+
*
|
|
130
|
+
* The walker reads `properties` and `items`. A subschema can also live under
|
|
131
|
+
* `$defs` behind a `$ref`, in a `oneOf`/`anyOf`/`allOf` branch, under
|
|
132
|
+
* `patternProperties` -- and mcp-manifest.schema.json already uses four of
|
|
133
|
+
* those. An annotation there used to be collected by nobody and reported by
|
|
134
|
+
* nobody: the record validated, the unknown tag went in, and the field simply
|
|
135
|
+
* stopped being governed by the taxonomy. Nothing distinguishes that from a
|
|
136
|
+
* field nobody annotated.
|
|
137
|
+
*
|
|
138
|
+
* The fix is the error, not reference resolution. Resolving `$ref` means
|
|
139
|
+
* owning a second schema engine -- cycles, remote ids, `$dynamicRef` -- to
|
|
140
|
+
* serve a shape no committed schema uses. Refusing at compile time costs one
|
|
141
|
+
* comparison and tells the schema author the one thing they need to know, at
|
|
142
|
+
* the moment they need it, instead of leaving them a validator that silently
|
|
143
|
+
* agrees with everything.
|
|
144
|
+
*/
|
|
145
|
+
function assertEveryAnnotationReachable(name, schema, pointers) {
|
|
146
|
+
const reachable = new Set(pointers.map((p) => p.schemaPath));
|
|
147
|
+
const unreachable = annotationPathsAnywhere(schema).filter((p) => !reachable.has(p));
|
|
148
|
+
if (unreachable.length === 0)
|
|
149
|
+
return;
|
|
150
|
+
throw new SchemaError('schema.unreachable-taxonomy', `Schema "${name}" annotates ${unreachable.length} field(s) with x-taxonomy where the ` +
|
|
151
|
+
`pointer walker cannot see them: ${unreachable.join(', ')}. It reads \`properties\` and ` +
|
|
152
|
+
'`items` only, so an annotation under `$defs`, `$ref`, `oneOf`, `anyOf`, `allOf` or ' +
|
|
153
|
+
'`patternProperties` would never be checked and the field would go ungoverned. Move it ' +
|
|
154
|
+
'onto the property (or onto `items`) that carries the value.', { schema: name, paths: unreachable });
|
|
155
|
+
}
|
|
156
|
+
function ajvErrorToIssue(err) {
|
|
157
|
+
return { path: err.instancePath || '/', message: `${err.instancePath} ${err.message}`.trim() };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Validate a record against a compiled schema by name, then resolve every
|
|
161
|
+
* x-taxonomy annotated field against the taxonomy so a schema-valid record
|
|
162
|
+
* with an unknown taxonomy value still fails.
|
|
163
|
+
*/
|
|
164
|
+
export function validateRecord(schemas, taxonomy, schemaName, record) {
|
|
165
|
+
const compiled = schemas.get(schemaName);
|
|
166
|
+
if (!compiled) {
|
|
167
|
+
throw new SchemaError('schema.unknown-name', `Unknown schema "${schemaName}".`, {
|
|
168
|
+
schemaName,
|
|
169
|
+
known: [...schemas.keys()],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const errors = [];
|
|
173
|
+
const schemaValid = compiled.validate(record);
|
|
174
|
+
if (!schemaValid) {
|
|
175
|
+
for (const err of compiled.validate.errors ?? []) {
|
|
176
|
+
errors.push(ajvErrorToIssue(err));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const pointer of compiled.taxonomyPointers) {
|
|
180
|
+
for (const hit of resolveAtPath(record, pointer.segments)) {
|
|
181
|
+
if (hit.value === undefined)
|
|
182
|
+
continue; // absence is the schema's job, not ours
|
|
183
|
+
const pathStr = `/${hit.path.join('/')}`;
|
|
184
|
+
try {
|
|
185
|
+
validateTag(taxonomy, pointer.dimension, String(hit.value));
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
errors.push({ path: pathStr, message: err instanceof Error ? err.message : String(err) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (errors.length > 0)
|
|
193
|
+
return { valid: false, errors };
|
|
194
|
+
return { valid: true };
|
|
195
|
+
}
|