@event4u/agent-config 14.13.0 → 14.14.1
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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +138 -376
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +36 -0
- package/dist/agent-src/commands/pr/create.md +16 -1
- package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
- package/dist/agent-src/rules/code-comment-discipline.md +6 -0
- package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
- package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
- package/dist/agent-src/scripts/stubs_due.ts +118 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
- package/dist/cli-delegate/cmd_conformance.js +39 -2
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +7 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1696 -812
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +297 -115
- package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
- package/docs/guidelines/code-clarity.md +18 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
- package/src/agent-src/scripts/guarded_baseline.ts +22 -7
- package/src/agent-src/scripts/stubs_due.ts +118 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
- package/src/config/assurance-capability-registry.json +1 -1
- package/src/config/estate-count-budget.json +20 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +211 -1
- package/src/config/gate-reachability-exemptions.json +1 -0
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/preamble-payload-budget.json +19 -11
- package/src/config/rule-stub-ceilings.json +14 -7
- package/src/config/web-launch-readiness.json +4 -2
- package/src/scripts/_cli/_doctor_emit.ts +71 -0
- package/src/scripts/_cli/_doctor_strict.ts +163 -0
- package/src/scripts/_cli/cmd_doctor.ts +16 -37
- package/src/scripts/_dispatch.bash +11 -4
- package/src/scripts/_lib/activation_ladder.ts +47 -3
- package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
- package/src/scripts/_lib/adr_frontmatter.ts +26 -0
- package/src/scripts/_lib/branch_convergence.ts +186 -0
- package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
- package/src/scripts/_lib/carrier_status.ts +118 -0
- package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
- package/src/scripts/_lib/context_observation.ts +132 -0
- package/src/scripts/_lib/continuation_ladder.ts +188 -0
- package/src/scripts/_lib/corpus_manifest.ts +726 -0
- package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
- package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
- package/src/scripts/_lib/evaluation_cascade.ts +452 -0
- package/src/scripts/_lib/evolution_roi.ts +562 -0
- package/src/scripts/_lib/experiment_binding.ts +95 -0
- package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
- package/src/scripts/_lib/ignored_blocker.ts +8 -1
- package/src/scripts/_lib/lexical_shortlist.ts +215 -0
- package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
- package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
- package/src/scripts/_lib/outcome_envelope.ts +6 -1
- package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
- package/src/scripts/_lib/pathology_archive.ts +393 -0
- package/src/scripts/_lib/promotion_capability.ts +315 -0
- package/src/scripts/_lib/promotion_evidence.ts +414 -0
- package/src/scripts/_lib/promotion_review.ts +294 -0
- package/src/scripts/_lib/prompt_shape.ts +92 -0
- package/src/scripts/_lib/release_highlights.ts +79 -1
- package/src/scripts/_lib/release_material.ts +125 -2
- package/src/scripts/_lib/repeated_failure.ts +19 -2
- package/src/scripts/_lib/routing_corpus.ts +307 -0
- package/src/scripts/_lib/routing_index_input.ts +104 -0
- package/src/scripts/_lib/rule_injection.ts +22 -1
- package/src/scripts/_lib/runtime_journal.ts +11 -4
- package/src/scripts/_lib/semantic_noop.ts +167 -0
- package/src/scripts/activation_receipt.ts +258 -0
- package/src/scripts/ai_council/cross_exam.ts +128 -0
- package/src/scripts/ai_council/explain_route.ts +157 -0
- package/src/scripts/ai_council/information_gain.ts +293 -0
- package/src/scripts/ai_council/leakage_corpus.ts +385 -0
- package/src/scripts/ai_council/leakage_patterns.ts +453 -0
- package/src/scripts/ai_council/minority_retention.ts +171 -0
- package/src/scripts/ai_council/recouncil_savings.ts +299 -0
- package/src/scripts/ai_council/replay_route.ts +223 -0
- package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
- package/src/scripts/ai_council/routing_training_row.ts +197 -0
- package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
- package/src/scripts/check_adr_frontmatter.ts +4 -9
- package/src/scripts/check_agent_artifact_location.ts +337 -0
- package/src/scripts/check_artefact_count_messaging.ts +74 -8
- package/src/scripts/check_enforcement_coverage.ts +40 -1
- package/src/scripts/check_estate_count.ts +205 -24
- package/src/scripts/check_release_highlights.ts +113 -14
- package/src/scripts/check_roadmap_trackable.ts +17 -2
- package/src/scripts/check_web_launch_readiness.ts +310 -20
- package/src/scripts/corpus_manifest.ts +235 -0
- package/src/scripts/evolution_lab.ts +234 -9
- package/src/scripts/generate_capability_matrix.ts +16 -9
- package/src/scripts/grade_target_readiness.ts +221 -2
- package/src/scripts/hook_effect_doctor.ts +47 -4
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +28 -6
- package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/run_continuation_hook.ts +78 -88
- package/src/scripts/hooks/session_eol_hook.ts +12 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
- package/src/scripts/install-hooks.sh +18 -0
- package/src/scripts/language_mirror_hook.ts +14 -2
- package/src/scripts/lint_carrier_integrity.ts +820 -0
- package/src/scripts/lint_code_comments.ts +748 -0
- package/src/scripts/lint_plan_risk_register.ts +39 -19
- package/src/scripts/lint_promotion_paths.ts +779 -0
- package/src/scripts/llm_propose.ts +256 -0
- package/src/scripts/measure_delivery_sets.ts +361 -0
- package/src/scripts/measure_routing_signal.ts +307 -0
- package/src/scripts/model_rule_injection.ts +19 -3
- package/src/scripts/probe_council_retention.ts +495 -0
- package/src/scripts/release.ts +21 -37
- package/src/scripts/release_drill.ts +199 -0
- package/src/scripts/release_env.ts +35 -0
- package/src/scripts/release_publication.ts +142 -0
- package/src/scripts/roadmap_context.ts +15 -0
- package/src/scripts/skill_scout.ts +633 -0
- package/src/scripts/sync_pr_branch.ts +521 -42
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The equivalence-preserving pin for the metered-proposer experiment's subject.
|
|
3
|
+
*
|
|
4
|
+
* The experiment enumerates its corpus from `.claude/rules/`, which is a
|
|
5
|
+
* GENERATED projection and is gitignored in its entirety. Two consequences,
|
|
6
|
+
* both measured rather than argued: the same commit yields a different corpus
|
|
7
|
+
* on two machines, and the generator SKIPS any rule already installed
|
|
8
|
+
* byte-identically at user scope, so the corpus is a function of the operator's
|
|
9
|
+
* home directory as well as of the commit. A recorded commit therefore does not
|
|
10
|
+
* pin the subject, and a comparison against a subject nobody can reconstruct is
|
|
11
|
+
* not comparable to anything.
|
|
12
|
+
*
|
|
13
|
+
* What this module pins instead is the whole of what decides the projection:
|
|
14
|
+
* the commit, the generator's own bytes, the ordered subject inventory with
|
|
15
|
+
* hashes and provenance, the included and excluded paths, every user-scope file
|
|
16
|
+
* capable of causing a skip, the generator configuration and the non-secret
|
|
17
|
+
* environment values it reads, the runtime and platform facts, and the produced
|
|
18
|
+
* file inventory. {@link subjectDigest} folds the subject half into one value,
|
|
19
|
+
* so two runs are comparable exactly when that value matches.
|
|
20
|
+
*
|
|
21
|
+
* The manifest is a CAPTURE, never a plan. It records the tree as it stands;
|
|
22
|
+
* whether a clean checkout reproduces it is the question {@link diffManifests}
|
|
23
|
+
* answers, and answering it requires actually regenerating in that checkout. A
|
|
24
|
+
* module that predicted the projection instead of reading it would be asserting
|
|
25
|
+
* the very equivalence it exists to test.
|
|
26
|
+
*
|
|
27
|
+
* PRIVACY. User-scope paths are recorded home-relative, so no account name
|
|
28
|
+
* leaves the machine, and only file hashes are stored — never content. The
|
|
29
|
+
* environment capture is a closed allowlist of non-secret keys rather than a
|
|
30
|
+
* filtered dump, because a filter is a list of what somebody remembered to
|
|
31
|
+
* exclude.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import * as fs from 'node:fs';
|
|
36
|
+
import * as os from 'node:os';
|
|
37
|
+
import * as path from 'node:path';
|
|
38
|
+
import { spawnSync } from 'node:child_process';
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
globalRuleLayerNames,
|
|
42
|
+
globalRuleLayerPath,
|
|
43
|
+
hostLayerCarries,
|
|
44
|
+
toolIdForProjectRuleDir,
|
|
45
|
+
} from '../../install/globalRuleLayers.js';
|
|
46
|
+
import { isExclusivelyPackageOnly } from '../../install/partitionEligibility.js';
|
|
47
|
+
import { dedupableRules } from '../../install/ruleLayerPartition.js';
|
|
48
|
+
|
|
49
|
+
export const CORPUS_MANIFEST_VERSION = 'corpus-manifest-v1';
|
|
50
|
+
|
|
51
|
+
/** The tool directory the experiment's corpus is enumerated from. */
|
|
52
|
+
export const CORPUS_TOOL_DIR = '.claude/rules';
|
|
53
|
+
|
|
54
|
+
/** Where a projected rule's bytes come from, and what the dedup compares against. */
|
|
55
|
+
export const PROJECTION_SOURCE_DIR = path.join('dist', 'agent-src', 'rules');
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The files whose bytes decide the projection, so a manifest can say WHICH
|
|
59
|
+
* generator produced it. Hashing the generator is the difference between "the
|
|
60
|
+
* corpus differs" and "the corpus differs because the generator changed".
|
|
61
|
+
*/
|
|
62
|
+
export const GENERATOR_MODULES: readonly string[] = [
|
|
63
|
+
path.join('src', 'scripts', 'condense.ts'),
|
|
64
|
+
path.join('src', 'install', 'ruleLayerPartition.ts'),
|
|
65
|
+
path.join('src', 'install', 'claudePathsPlan.ts'),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Non-secret environment keys the projection actually reads.
|
|
70
|
+
*
|
|
71
|
+
* A closed list, and `HOME` is deliberately absent: it decides the dedup, but
|
|
72
|
+
* its VALUE is an account name. The dedup evidence is carried by the
|
|
73
|
+
* user-scope hash table instead, which says what was found without saying who
|
|
74
|
+
* owns it.
|
|
75
|
+
*/
|
|
76
|
+
export const CAPTURED_ENV_KEYS: readonly string[] = [
|
|
77
|
+
'AGENT_CONFIG_DEV_MODE',
|
|
78
|
+
'AGENT_CONFIG_SCOPE',
|
|
79
|
+
'AGENT_CONFIG_PROJECTION_MODE',
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
export class CorpusManifestError extends Error {
|
|
83
|
+
constructor(message: string) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = 'CorpusManifestError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SubjectEntry {
|
|
90
|
+
/** Repo-relative path of the projected file the experiment mutates. */
|
|
91
|
+
readonly path: string;
|
|
92
|
+
readonly sha256: string;
|
|
93
|
+
readonly bytes: number;
|
|
94
|
+
/** The projection source this file was emitted from, repo-relative. */
|
|
95
|
+
readonly provenance: string;
|
|
96
|
+
/** Hash of that source, or `null` when it is absent from this tree. */
|
|
97
|
+
readonly provenance_sha256: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ExcludedEntry {
|
|
101
|
+
readonly path: string;
|
|
102
|
+
readonly why: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface UserScopeTwin {
|
|
106
|
+
/** Home-relative, never absolute: the account name is not evidence. */
|
|
107
|
+
readonly path: string;
|
|
108
|
+
readonly sha256: string;
|
|
109
|
+
/** The raw comparison: this twin is byte-identical to the projection source. */
|
|
110
|
+
readonly byte_identical: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Byte-identical AND the generator's byte-identity dedup is switched on.
|
|
113
|
+
*
|
|
114
|
+
* The two are separate fields because they answer separate questions and
|
|
115
|
+
* folding them recorded a skip that did not happen. The generator calls
|
|
116
|
+
* `dedupableRules` only when `projection.scope_dedup` resolves true; that
|
|
117
|
+
* setting is absent on every layer this repository carries, so on this tree
|
|
118
|
+
* a byte-identical twin causes nothing. A manifest that asserted otherwise
|
|
119
|
+
* would re-introduce the very mis-attribution the projection block below
|
|
120
|
+
* was written to correct.
|
|
121
|
+
*/
|
|
122
|
+
readonly causes_skip: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The evidence that decides which rules reach the corpus directory at all.
|
|
127
|
+
*
|
|
128
|
+
* This block exists because the byte-identity table below accounts for NONE of
|
|
129
|
+
* the skips actually observed. Measured on a freshly generated tree: the
|
|
130
|
+
* generator reported 101 rules skipped and the byte-identity comparison
|
|
131
|
+
* returned zero, because `projection.scope_dedup` is absent on every layer this
|
|
132
|
+
* repository carries and therefore off. The real mechanism is the per-host rule
|
|
133
|
+
* partition, which withholds a rule when this host's GLOBAL layer is verified
|
|
134
|
+
* to carry its NAME. So the field a recorded commit cannot supply is the global
|
|
135
|
+
* layer's name inventory, and a manifest that pinned only byte-identical twins
|
|
136
|
+
* would have missed every skip that happened.
|
|
137
|
+
*/
|
|
138
|
+
export interface ProjectionDecision {
|
|
139
|
+
/**
|
|
140
|
+
* Whether the per-host partition is active, or `null` when not captured.
|
|
141
|
+
*
|
|
142
|
+
* `null` is the default and is the honest value for a capture that cannot
|
|
143
|
+
* establish it. Every other field in this struct is a function of the
|
|
144
|
+
* `(repoRoot, userHome)` pair the capture was given; this one is not.
|
|
145
|
+
* `partitionActive` resolves through `resolvePartitionVerdict`, which reads
|
|
146
|
+
* the host layer at `os.homedir()` and the install lockfile at its own
|
|
147
|
+
* fixed location — ignoring `userHome` entirely — and memoises the answer
|
|
148
|
+
* for the life of the process. So a capture over a temporary tree that
|
|
149
|
+
* called it would record the operator's machine, and a second capture in
|
|
150
|
+
* one process would reuse the first verdict. A caller for which those
|
|
151
|
+
* globals ARE the run's own host supplies the predicate; nobody else does.
|
|
152
|
+
*/
|
|
153
|
+
readonly partition_active: boolean | null;
|
|
154
|
+
readonly tool_id: string | null;
|
|
155
|
+
/** The global layer directory, home-relative; `null` when this host has none. */
|
|
156
|
+
readonly layer_dir: string | null;
|
|
157
|
+
readonly carries: boolean;
|
|
158
|
+
readonly reason: string;
|
|
159
|
+
/** Names the partition would withhold that the global layer does not carry. */
|
|
160
|
+
readonly missing: readonly string[];
|
|
161
|
+
/**
|
|
162
|
+
* Rules classified exclusively package-only, and therefore never withheld.
|
|
163
|
+
*
|
|
164
|
+
* Counted over every rule in the projection source, which is a SUPERSET of
|
|
165
|
+
* what the generator classifies: the generator filters by workspace scope
|
|
166
|
+
* and by manual type first. So this number is an explanatory input and is
|
|
167
|
+
* not the produced count. Measured on one tree: 15 here against 13 files
|
|
168
|
+
* actually produced. `produced` is the authoritative inventory.
|
|
169
|
+
*/
|
|
170
|
+
readonly package_only_count: number;
|
|
171
|
+
/** The global layer inventory, with hashes: names decide, hashes explain. */
|
|
172
|
+
readonly layer_inventory: readonly { readonly name: string; readonly sha256: string | null }[];
|
|
173
|
+
/** Folds the inventory into one comparable value. */
|
|
174
|
+
readonly layer_digest: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface CorpusManifest {
|
|
178
|
+
readonly version: typeof CORPUS_MANIFEST_VERSION;
|
|
179
|
+
readonly commit: string | null;
|
|
180
|
+
/** True when the working tree carries uncommitted changes at capture time. */
|
|
181
|
+
readonly tree_dirty: boolean;
|
|
182
|
+
readonly package_version: string | null;
|
|
183
|
+
readonly generator: readonly { readonly path: string; readonly sha256: string | null }[];
|
|
184
|
+
/** The enumeration rule this manifest was captured under, by id. */
|
|
185
|
+
readonly enumeration_rule: string;
|
|
186
|
+
readonly included: readonly SubjectEntry[];
|
|
187
|
+
readonly excluded: readonly ExcludedEntry[];
|
|
188
|
+
readonly user_scope: readonly UserScopeTwin[];
|
|
189
|
+
/** Which rule population the user-scope table was built over. */
|
|
190
|
+
readonly user_scope_population: UserScopePopulation;
|
|
191
|
+
readonly projection: ProjectionDecision;
|
|
192
|
+
readonly generator_config: Readonly<Record<string, string | null>>;
|
|
193
|
+
readonly runtime: {
|
|
194
|
+
readonly node: string;
|
|
195
|
+
readonly platform: string;
|
|
196
|
+
readonly arch: string;
|
|
197
|
+
};
|
|
198
|
+
/** Every file the projection produced in the corpus directory, with hashes. */
|
|
199
|
+
readonly produced: readonly { readonly path: string; readonly sha256: string }[];
|
|
200
|
+
/** Folds the subject half into one comparable value. */
|
|
201
|
+
readonly subject_digest: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function sha256OfFile(abs: string): string | null {
|
|
205
|
+
try {
|
|
206
|
+
return createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sha256OfString(s: string): string {
|
|
213
|
+
return createHash('sha256').update(s, 'utf-8').digest('hex');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Byte-wise, locale-independent order. Never `localeCompare`. */
|
|
217
|
+
function byteCompare(a: string, b: string): number {
|
|
218
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function git(root: string, args: readonly string[]): string | null {
|
|
222
|
+
const r = spawnSync('git', [...args], { cwd: root, encoding: 'utf-8' });
|
|
223
|
+
if (r.status !== 0 || typeof r.stdout !== 'string') return null;
|
|
224
|
+
return r.stdout.trim();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The corpus enumeration, executable rather than prose.
|
|
229
|
+
*
|
|
230
|
+
* Until now the rule lived only in the protocol document, so nothing could
|
|
231
|
+
* check that a capture had followed it. The four clauses are: every `*.md`
|
|
232
|
+
* directly under the corpus directory, sorted byte-wise by filename, the first
|
|
233
|
+
* `limit` of them, and the rest recorded as excluded with the reason. Reading
|
|
234
|
+
* the directory here rather than predicting it is deliberate: what the
|
|
235
|
+
* experiment mutates is what is on disk.
|
|
236
|
+
*/
|
|
237
|
+
export function enumerateCorpus(
|
|
238
|
+
repoRoot: string,
|
|
239
|
+
limit: number,
|
|
240
|
+
): { included: string[]; excluded: ExcludedEntry[] } {
|
|
241
|
+
const dir = path.join(repoRoot, CORPUS_TOOL_DIR);
|
|
242
|
+
let names: string[];
|
|
243
|
+
try {
|
|
244
|
+
names = fs
|
|
245
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
246
|
+
.filter((d) => d.isFile() || d.isSymbolicLink())
|
|
247
|
+
.map((d) => d.name)
|
|
248
|
+
.filter((n) => n.endsWith('.md'));
|
|
249
|
+
} catch {
|
|
250
|
+
throw new CorpusManifestError(
|
|
251
|
+
`${CORPUS_TOOL_DIR} does not exist in ${repoRoot} — the corpus is a generated ` +
|
|
252
|
+
'projection, so a capture before generation would pin an empty subject and ' +
|
|
253
|
+
'read as a successful pin of nothing',
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (names.length === 0) {
|
|
257
|
+
throw new CorpusManifestError(
|
|
258
|
+
`${CORPUS_TOOL_DIR} exists in ${repoRoot} but holds no *.md file — the corpus is a ` +
|
|
259
|
+
'generated projection, so an existing-but-empty directory pins an empty subject ' +
|
|
260
|
+
'exactly as an absent one does. Two empty pins share a digest and compare ' +
|
|
261
|
+
'SUBJECT EQUIVALENT, which certifies two runs that measured nothing as having ' +
|
|
262
|
+
'measured the same thing. The directory survives a generation that emitted zero ' +
|
|
263
|
+
'files and an operator clearing its contents, so this is the reachable half',
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
names.sort(byteCompare);
|
|
267
|
+
const included = names.slice(0, limit);
|
|
268
|
+
const excluded = names.slice(limit).map((n) => ({
|
|
269
|
+
path: path.posix.join(CORPUS_TOOL_DIR, n),
|
|
270
|
+
why: `beyond the first ${String(limit)} in byte order`,
|
|
271
|
+
}));
|
|
272
|
+
return { included: included.map((n) => path.posix.join(CORPUS_TOOL_DIR, n)), excluded };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Which rule population a user-scope table was built over.
|
|
277
|
+
*
|
|
278
|
+
* `projection-source-superset` is the default and is a SUPERSET of what the
|
|
279
|
+
* generator considers: the generator filters the same directory by workspace
|
|
280
|
+
* scope, pack scope, ADR-004 manual type and the compile-disabled toggle before
|
|
281
|
+
* it looks for twins. Replicating those four filters here would be the
|
|
282
|
+
* re-implementation this module refuses everywhere else, so the population is
|
|
283
|
+
* recorded rather than silently narrowed, and a caller holding the generator's
|
|
284
|
+
* own list passes it and gets `caller-supplied`.
|
|
285
|
+
*/
|
|
286
|
+
export type UserScopePopulation = 'projection-source-superset' | 'caller-supplied';
|
|
287
|
+
|
|
288
|
+
export interface UserScopeOptions {
|
|
289
|
+
/**
|
|
290
|
+
* Whether the generator's byte-identity dedup runs at all.
|
|
291
|
+
*
|
|
292
|
+
* Required rather than defaulted. A default here is the defect: the twin
|
|
293
|
+
* table is evidence about a mechanism that is switched off on this tree,
|
|
294
|
+
* and a capture that did not read the switch cannot say whether a twin
|
|
295
|
+
* skips anything.
|
|
296
|
+
*/
|
|
297
|
+
readonly dedupEnabled: boolean;
|
|
298
|
+
/** The generator's own candidate rule basenames, when the caller has them. */
|
|
299
|
+
readonly ruleNames?: readonly string[] | undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Every user-scope file that can suppress a rule at project scope, with hashes.
|
|
304
|
+
*
|
|
305
|
+
* This is the field a recorded commit cannot supply and the one that made the
|
|
306
|
+
* subject irreproducible. `byte_identical` replays the generator's own
|
|
307
|
+
* comparison — byte-identity against the projection source — rather than
|
|
308
|
+
* re-implementing a judgement about it. `causes_skip` is that fact conjoined
|
|
309
|
+
* with whether the generator runs the comparison at all, so a manifest and a
|
|
310
|
+
* generation cannot disagree about what a skip is.
|
|
311
|
+
*/
|
|
312
|
+
export function captureUserScope(
|
|
313
|
+
repoRoot: string,
|
|
314
|
+
userHome: string,
|
|
315
|
+
opts: UserScopeOptions,
|
|
316
|
+
): UserScopeTwin[] {
|
|
317
|
+
const sourceDir = path.join(repoRoot, PROJECTION_SOURCE_DIR);
|
|
318
|
+
let names: string[] = [];
|
|
319
|
+
if (opts.ruleNames === undefined) {
|
|
320
|
+
try {
|
|
321
|
+
names = fs
|
|
322
|
+
.readdirSync(sourceDir)
|
|
323
|
+
.filter((n) => n.endsWith('.md'))
|
|
324
|
+
.sort(byteCompare);
|
|
325
|
+
} catch {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
names = [...opts.ruleNames].sort(byteCompare);
|
|
330
|
+
}
|
|
331
|
+
const identical = dedupableRules({
|
|
332
|
+
toolDir: CORPUS_TOOL_DIR,
|
|
333
|
+
rules: names,
|
|
334
|
+
userHome,
|
|
335
|
+
rulesSource: sourceDir,
|
|
336
|
+
});
|
|
337
|
+
const out: UserScopeTwin[] = [];
|
|
338
|
+
for (const n of names) {
|
|
339
|
+
const rel = path.posix.join('.claude', 'rules', n);
|
|
340
|
+
const hash = sha256OfFile(path.join(userHome, '.claude', 'rules', n));
|
|
341
|
+
if (hash === null) continue;
|
|
342
|
+
const same = identical.has(n);
|
|
343
|
+
out.push({
|
|
344
|
+
path: rel,
|
|
345
|
+
sha256: hash,
|
|
346
|
+
byte_identical: same,
|
|
347
|
+
causes_skip: same && opts.dedupEnabled,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Capture the partition evidence, reading the same predicates the generator
|
|
355
|
+
* reads rather than a re-implementation of them.
|
|
356
|
+
*
|
|
357
|
+
* `hostLayerCarries` decides on NAME presence, not on content, so the names are
|
|
358
|
+
* what pin the corpus and the hashes are recorded alongside only to explain a
|
|
359
|
+
* difference. Recording the hashes without the names would have been the wrong
|
|
360
|
+
* half: two layers holding the same names with different bytes produce the same
|
|
361
|
+
* corpus.
|
|
362
|
+
*/
|
|
363
|
+
export interface ProjectionDecisionOptions {
|
|
364
|
+
/**
|
|
365
|
+
* The host partition verdict, supplied by a caller for which the process
|
|
366
|
+
* globals it reads are this run's own host. Omitted → `null`.
|
|
367
|
+
*/
|
|
368
|
+
readonly partitionActive?: ((repoRoot: string) => boolean) | undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function captureProjectionDecision(
|
|
372
|
+
repoRoot: string,
|
|
373
|
+
userHome: string,
|
|
374
|
+
opts: ProjectionDecisionOptions = {},
|
|
375
|
+
): ProjectionDecision {
|
|
376
|
+
const sourceDir = path.join(repoRoot, PROJECTION_SOURCE_DIR);
|
|
377
|
+
let names: string[] = [];
|
|
378
|
+
try {
|
|
379
|
+
names = fs
|
|
380
|
+
.readdirSync(sourceDir)
|
|
381
|
+
.filter((n) => n.endsWith('.md'))
|
|
382
|
+
.sort(byteCompare);
|
|
383
|
+
} catch {
|
|
384
|
+
names = [];
|
|
385
|
+
}
|
|
386
|
+
const toolId = toolIdForProjectRuleDir(CORPUS_TOOL_DIR);
|
|
387
|
+
const packageOnly = names.filter((n) => isExclusivelyPackageOnly(path.join(sourceDir, n)));
|
|
388
|
+
const keep = new Set(packageOnly);
|
|
389
|
+
const withheld = names.filter((n) => !keep.has(n));
|
|
390
|
+
const verdict =
|
|
391
|
+
toolId === null
|
|
392
|
+
? { carries: false, layerPath: null, missing: withheld, reason: 'no-layer-for-host' }
|
|
393
|
+
: hostLayerCarries(toolId, withheld, userHome);
|
|
394
|
+
const layerNames = toolId === null ? null : globalRuleLayerNames(toolId, userHome);
|
|
395
|
+
const layerPath = toolId === null ? null : globalRuleLayerPath(toolId, userHome);
|
|
396
|
+
const inventory = (layerNames ?? [])
|
|
397
|
+
.slice()
|
|
398
|
+
.sort(byteCompare)
|
|
399
|
+
.map((n) => ({
|
|
400
|
+
name: n,
|
|
401
|
+
sha256: layerPath === null ? null : sha256OfFile(path.join(layerPath, n)),
|
|
402
|
+
}));
|
|
403
|
+
return {
|
|
404
|
+
partition_active: opts.partitionActive === undefined ? null : opts.partitionActive(repoRoot),
|
|
405
|
+
tool_id: toolId,
|
|
406
|
+
layer_dir: layerPath === null ? null : homeRelative(layerPath, userHome),
|
|
407
|
+
carries: verdict.carries,
|
|
408
|
+
reason: verdict.reason,
|
|
409
|
+
missing: [...verdict.missing].sort(byteCompare),
|
|
410
|
+
package_only_count: packageOnly.length,
|
|
411
|
+
layer_inventory: inventory,
|
|
412
|
+
layer_digest: sha256OfString(inventory.map((e) => `${e.name} ${String(e.sha256)}`).join('\n')),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* A path with the home prefix replaced, so no account name is captured.
|
|
418
|
+
*
|
|
419
|
+
* Falls back to the basename rather than to the absolute path when the prefix
|
|
420
|
+
* does not match: a fallback that leaked the full path would defeat the whole
|
|
421
|
+
* point on exactly the machines where the home layout is unusual.
|
|
422
|
+
*/
|
|
423
|
+
export function homeRelative(abs: string, userHome: string): string {
|
|
424
|
+
const rel = path.relative(userHome, abs);
|
|
425
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
426
|
+
return path.basename(abs);
|
|
427
|
+
}
|
|
428
|
+
return rel.split(path.sep).join('/');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The one value two runs compare on.
|
|
433
|
+
*
|
|
434
|
+
* Folds the ordered subject inventory and the enumeration rule, and nothing
|
|
435
|
+
* else. The commit, the runtime and the user-scope table are all EXPLANATIONS
|
|
436
|
+
* of a difference and must not be inside the value that detects one — a digest
|
|
437
|
+
* that changed when the node version changed would report a subject change on
|
|
438
|
+
* every upgrade, and a reader would stop believing it.
|
|
439
|
+
*/
|
|
440
|
+
export function subjectDigest(
|
|
441
|
+
enumerationRule: string,
|
|
442
|
+
included: readonly SubjectEntry[],
|
|
443
|
+
): string {
|
|
444
|
+
const body = [enumerationRule, ...included.map((e) => `${e.path} ${e.sha256}`)].join('\n');
|
|
445
|
+
return sha256OfString(body);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export interface CaptureOptions {
|
|
449
|
+
readonly repoRoot: string;
|
|
450
|
+
readonly userHome: string;
|
|
451
|
+
readonly limit: number;
|
|
452
|
+
readonly enumerationRule: string;
|
|
453
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
454
|
+
/** See {@link ProjectionDecisionOptions.partitionActive}. Omitted → `null`. */
|
|
455
|
+
readonly partitionActive?: ((repoRoot: string) => boolean) | undefined;
|
|
456
|
+
/** See {@link UserScopeOptions.ruleNames}. Omitted → the projection-source superset. */
|
|
457
|
+
readonly ruleNames?: readonly string[] | undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function captureManifest(opts: CaptureOptions): CorpusManifest {
|
|
461
|
+
const { repoRoot, userHome, limit, enumerationRule } = opts;
|
|
462
|
+
const env = opts.env ?? process.env;
|
|
463
|
+
const { included: paths, excluded } = enumerateCorpus(repoRoot, limit);
|
|
464
|
+
|
|
465
|
+
const included: SubjectEntry[] = paths.map((rel) => {
|
|
466
|
+
const abs = path.join(repoRoot, rel);
|
|
467
|
+
const hash = sha256OfFile(abs);
|
|
468
|
+
if (hash === null) {
|
|
469
|
+
throw new CorpusManifestError(`corpus member '${rel}' vanished between listing and read`);
|
|
470
|
+
}
|
|
471
|
+
const base = path.basename(rel);
|
|
472
|
+
const provenance = path.posix.join('dist', 'agent-src', 'rules', base);
|
|
473
|
+
return {
|
|
474
|
+
path: rel,
|
|
475
|
+
sha256: hash,
|
|
476
|
+
bytes: fs.statSync(abs).size,
|
|
477
|
+
provenance,
|
|
478
|
+
provenance_sha256: sha256OfFile(path.join(repoRoot, provenance)),
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
let produced: { path: string; sha256: string }[] = [];
|
|
483
|
+
try {
|
|
484
|
+
produced = fs
|
|
485
|
+
.readdirSync(path.join(repoRoot, CORPUS_TOOL_DIR))
|
|
486
|
+
.filter((n) => n.endsWith('.md'))
|
|
487
|
+
.sort(byteCompare)
|
|
488
|
+
.map((n) => ({
|
|
489
|
+
path: path.posix.join(CORPUS_TOOL_DIR, n),
|
|
490
|
+
sha256: sha256OfFile(path.join(repoRoot, CORPUS_TOOL_DIR, n)) ?? '',
|
|
491
|
+
}));
|
|
492
|
+
} catch {
|
|
493
|
+
produced = [];
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const generatorConfig: Record<string, string | null> = {};
|
|
497
|
+
for (const k of CAPTURED_ENV_KEYS) {
|
|
498
|
+
generatorConfig[k] = env[k] ?? null;
|
|
499
|
+
}
|
|
500
|
+
const scopeDedup = readScopeDedup(repoRoot);
|
|
501
|
+
generatorConfig['projection.scope_dedup'] = scopeDedup;
|
|
502
|
+
|
|
503
|
+
let packageVersion: string | null = null;
|
|
504
|
+
try {
|
|
505
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf-8')) as {
|
|
506
|
+
version?: unknown;
|
|
507
|
+
};
|
|
508
|
+
packageVersion = typeof pkg.version === 'string' ? pkg.version : null;
|
|
509
|
+
} catch {
|
|
510
|
+
packageVersion = null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const status = git(repoRoot, ['status', '--porcelain']);
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
version: CORPUS_MANIFEST_VERSION,
|
|
517
|
+
commit: git(repoRoot, ['rev-parse', 'HEAD']),
|
|
518
|
+
tree_dirty: status === null ? true : status !== '',
|
|
519
|
+
package_version: packageVersion,
|
|
520
|
+
generator: GENERATOR_MODULES.map((rel) => ({
|
|
521
|
+
path: rel,
|
|
522
|
+
sha256: sha256OfFile(path.join(repoRoot, rel)),
|
|
523
|
+
})),
|
|
524
|
+
enumeration_rule: enumerationRule,
|
|
525
|
+
included,
|
|
526
|
+
excluded,
|
|
527
|
+
user_scope: captureUserScope(repoRoot, userHome, {
|
|
528
|
+
dedupEnabled: scopeDedupEnabled(scopeDedup),
|
|
529
|
+
ruleNames: opts.ruleNames,
|
|
530
|
+
}),
|
|
531
|
+
user_scope_population:
|
|
532
|
+
opts.ruleNames === undefined ? 'projection-source-superset' : 'caller-supplied',
|
|
533
|
+
projection: captureProjectionDecision(repoRoot, userHome, {
|
|
534
|
+
partitionActive: opts.partitionActive,
|
|
535
|
+
}),
|
|
536
|
+
generator_config: generatorConfig,
|
|
537
|
+
runtime: { node: process.version, platform: os.platform(), arch: os.arch() },
|
|
538
|
+
produced,
|
|
539
|
+
subject_digest: subjectDigest(enumerationRule, included),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The generator setting that decides whether user-scope dedup runs at all.
|
|
545
|
+
*
|
|
546
|
+
* Read as raw text rather than through the settings resolver, because a
|
|
547
|
+
* manifest must record what the file SAID at capture time; a resolver applies a
|
|
548
|
+
* cascade whose upper layers are not in this repository and would silently
|
|
549
|
+
* substitute one machine's answer for another's.
|
|
550
|
+
*/
|
|
551
|
+
export function readScopeDedup(repoRoot: string): string | null {
|
|
552
|
+
for (const rel of ['.agent-settings.yml', path.join('src', 'config', 'agent-settings.template.yml')]) {
|
|
553
|
+
let text: string;
|
|
554
|
+
try {
|
|
555
|
+
text = fs.readFileSync(path.join(repoRoot, rel), 'utf-8');
|
|
556
|
+
} catch {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const m = /^\s*scope_dedup:\s*(\S+)/m.exec(text);
|
|
560
|
+
if (m) return `${rel}:${m[1] as string}`;
|
|
561
|
+
}
|
|
562
|
+
// Not `null`: absent is a value here, and the reader has to be able to tell
|
|
563
|
+
// it from a read that failed. Absent means the byte-identity dedup is off,
|
|
564
|
+
// which is what makes the partition the only live skip mechanism.
|
|
565
|
+
return 'absent:default-off';
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Does the recorded `projection.scope_dedup` value switch the dedup ON?
|
|
570
|
+
*
|
|
571
|
+
* Reads the value half of what {@link readScopeDedup} recorded, using the same
|
|
572
|
+
* truthy set the generator's own reader uses, so the manifest's `causes_skip`
|
|
573
|
+
* and the generator's behaviour cannot disagree. `absent:default-off` is false,
|
|
574
|
+
* which is the whole reason the field exists as a string in the first place.
|
|
575
|
+
*/
|
|
576
|
+
export function scopeDedupEnabled(recorded: string | null): boolean {
|
|
577
|
+
if (recorded === null) return false;
|
|
578
|
+
const value = recorded.slice(recorded.lastIndexOf(':') + 1).trim().toLowerCase();
|
|
579
|
+
return ['true', 'yes', 'on', '1'].includes(value);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export interface ManifestDifference {
|
|
583
|
+
readonly field: string;
|
|
584
|
+
readonly expected: string;
|
|
585
|
+
readonly actual: string;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The reconstruction check, and it reports EVERY difference rather than the
|
|
590
|
+
* first.
|
|
591
|
+
*
|
|
592
|
+
* Stopping at the first mismatch would let an operator fix one hash, re-run,
|
|
593
|
+
* and meet the next one — a check that reveals its findings one per cycle
|
|
594
|
+
* trains the reader to believe the last one was the last one. The subject
|
|
595
|
+
* differences are listed first because they are the ones that invalidate a
|
|
596
|
+
* comparison; the rest explain why they happened.
|
|
597
|
+
*/
|
|
598
|
+
export function diffManifests(expected: CorpusManifest, actual: CorpusManifest): ManifestDifference[] {
|
|
599
|
+
const out: ManifestDifference[] = [];
|
|
600
|
+
const push = (field: string, e: unknown, a: unknown): void => {
|
|
601
|
+
if (String(e) !== String(a)) out.push({ field, expected: String(e), actual: String(a) });
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
push('subject_digest', expected.subject_digest, actual.subject_digest);
|
|
605
|
+
push('enumeration_rule', expected.enumeration_rule, actual.enumeration_rule);
|
|
606
|
+
push('included.length', expected.included.length, actual.included.length);
|
|
607
|
+
|
|
608
|
+
const actualByPath = new Map(actual.included.map((e) => [e.path, e]));
|
|
609
|
+
for (const e of expected.included) {
|
|
610
|
+
const a = actualByPath.get(e.path);
|
|
611
|
+
if (a === undefined) {
|
|
612
|
+
out.push({ field: `included:${e.path}`, expected: e.sha256, actual: 'ABSENT' });
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
push(`included:${e.path}`, e.sha256, a.sha256);
|
|
616
|
+
}
|
|
617
|
+
const expectedPaths = new Set(expected.included.map((e) => e.path));
|
|
618
|
+
for (const a of actual.included) {
|
|
619
|
+
if (!expectedPaths.has(a.path)) {
|
|
620
|
+
out.push({ field: `included:${a.path}`, expected: 'ABSENT', actual: a.sha256 });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
push('commit', expected.commit, actual.commit);
|
|
625
|
+
push('produced.length', expected.produced.length, actual.produced.length);
|
|
626
|
+
for (const g of expected.generator) {
|
|
627
|
+
const a = actual.generator.find((x) => x.path === g.path);
|
|
628
|
+
// `NO-ROW` rather than a nullish fallback: a generator module absent
|
|
629
|
+
// from the tree hashes to `null`, and folding the two states together
|
|
630
|
+
// reported a difference between two captures that had both recorded the
|
|
631
|
+
// same absence. The states are three, not two, and the third is common.
|
|
632
|
+
push(`generator:${g.path}`, g.sha256, a === undefined ? 'NO-ROW' : a.sha256);
|
|
633
|
+
}
|
|
634
|
+
const skipping = (m: CorpusManifest): string =>
|
|
635
|
+
m.user_scope.filter((t) => t.causes_skip).map((t) => t.path).sort(byteCompare).join(',');
|
|
636
|
+
const identical = (m: CorpusManifest): string =>
|
|
637
|
+
m.user_scope.filter((t) => t.byte_identical).map((t) => t.path).sort(byteCompare).join(',');
|
|
638
|
+
push('user_scope.skipping', skipping(expected), skipping(actual));
|
|
639
|
+
// Both halves of the skip verdict, because `skipping` alone cannot tell a
|
|
640
|
+
// tree with no twins from a tree whose twins are switched off — the two
|
|
641
|
+
// states differ in what a later generation would do. The configuration keys
|
|
642
|
+
// are the other half and were outside the comparison entirely, so the one
|
|
643
|
+
// field that disambiguates a mis-attributed skip was unchecked.
|
|
644
|
+
push('user_scope.byte_identical', identical(expected), identical(actual));
|
|
645
|
+
push('user_scope_population', expected.user_scope_population, actual.user_scope_population);
|
|
646
|
+
for (const k of [...new Set([
|
|
647
|
+
...Object.keys(expected.generator_config),
|
|
648
|
+
...Object.keys(actual.generator_config),
|
|
649
|
+
])].sort(byteCompare)) {
|
|
650
|
+
push(
|
|
651
|
+
`generator_config:${k}`,
|
|
652
|
+
k in expected.generator_config ? expected.generator_config[k] : 'NO-KEY',
|
|
653
|
+
k in actual.generator_config ? actual.generator_config[k] : 'NO-KEY',
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
// The partition half, which is where the observed skips actually came from.
|
|
657
|
+
// Diffed by digest rather than by name list because the inventory runs to
|
|
658
|
+
// three figures and a diff nobody reads is a diff that does not exist.
|
|
659
|
+
push('projection.partition_active', expected.projection.partition_active, actual.projection.partition_active);
|
|
660
|
+
push('projection.carries', expected.projection.carries, actual.projection.carries);
|
|
661
|
+
push('projection.reason', expected.projection.reason, actual.projection.reason);
|
|
662
|
+
push('projection.layer_digest', expected.projection.layer_digest, actual.projection.layer_digest);
|
|
663
|
+
push(
|
|
664
|
+
'projection.layer_names',
|
|
665
|
+
expected.projection.layer_inventory.map((e) => e.name).join(','),
|
|
666
|
+
actual.projection.layer_inventory.map((e) => e.name).join(','),
|
|
667
|
+
);
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Two manifests describe the same experimental subject. Nothing weaker. */
|
|
672
|
+
export function subjectsEquivalent(expected: CorpusManifest, actual: CorpusManifest): boolean {
|
|
673
|
+
return (
|
|
674
|
+
expected.subject_digest === actual.subject_digest &&
|
|
675
|
+
expected.enumeration_rule === actual.enumeration_rule
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export function serialiseManifest(m: CorpusManifest): string {
|
|
680
|
+
return `${JSON.stringify(m, null, 2)}\n`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export function parseManifest(raw: unknown): CorpusManifest {
|
|
684
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
685
|
+
throw new CorpusManifestError('a manifest must be a JSON object');
|
|
686
|
+
}
|
|
687
|
+
const o = raw as Record<string, unknown>;
|
|
688
|
+
if (o['version'] !== CORPUS_MANIFEST_VERSION) {
|
|
689
|
+
throw new CorpusManifestError(
|
|
690
|
+
`manifest version ${JSON.stringify(o['version'])} is not ${CORPUS_MANIFEST_VERSION} — ` +
|
|
691
|
+
'a manifest of another shape cannot be compared field-for-field, and comparing it ' +
|
|
692
|
+
'partially would report equivalence from the fields that happened to survive',
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (typeof o['subject_digest'] !== 'string' || !Array.isArray(o['included'])) {
|
|
696
|
+
throw new CorpusManifestError('manifest is missing subject_digest or included');
|
|
697
|
+
}
|
|
698
|
+
const rule = o['enumeration_rule'];
|
|
699
|
+
if (typeof rule !== 'string' || rule === '') {
|
|
700
|
+
throw new CorpusManifestError(
|
|
701
|
+
'manifest is missing enumeration_rule, which is inside the digest and decides which ' +
|
|
702
|
+
'subjects the rule selected — a pin without it cannot be recomputed',
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
const included = o['included'] as readonly SubjectEntry[];
|
|
706
|
+
if (included.length === 0) {
|
|
707
|
+
throw new CorpusManifestError(
|
|
708
|
+
'manifest pins an empty subject: two empty pins share a digest and compare equivalent, ' +
|
|
709
|
+
'so accepting one would let a verification certify that nothing was measured twice',
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
// The pin adjudicates against its own stored digest, so a manifest whose
|
|
713
|
+
// digest and body disagree — hand-edited, truncated, or emitted by a future
|
|
714
|
+
// bug — would be judged on a value its `included` list does not support,
|
|
715
|
+
// while the per-path rows printed beside the verdict described a different
|
|
716
|
+
// subject. Recomputing is one call to the function that wrote it.
|
|
717
|
+
const recomputed = subjectDigest(rule, included);
|
|
718
|
+
if (recomputed !== o['subject_digest']) {
|
|
719
|
+
throw new CorpusManifestError(
|
|
720
|
+
`manifest subject_digest ${o['subject_digest']} does not match the digest of its own ` +
|
|
721
|
+
`enumeration rule and included list (${recomputed}) — the two disagree, so neither ` +
|
|
722
|
+
'can be used to decide whether another capture measured the same subject',
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
return raw as CorpusManifest;
|
|
726
|
+
}
|