@adrkit/evaluator 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.
Files changed (81) hide show
  1. package/README.md +23 -0
  2. package/dist/LICENSE +201 -0
  3. package/dist/NOTICE +11 -0
  4. package/dist/assertions/evaluate.d.ts +36 -0
  5. package/dist/assertions/jsonpath.d.ts +20 -0
  6. package/dist/assertions/limits.d.ts +21 -0
  7. package/dist/assertions/registry.d.ts +20 -0
  8. package/dist/assertions/rego.d.ts +27 -0
  9. package/dist/catalog.d.ts +39 -0
  10. package/dist/compare.d.ts +10 -0
  11. package/dist/crypto/sha256.d.ts +10 -0
  12. package/dist/identity/directory.d.ts +23 -0
  13. package/dist/index.d.ts +24 -0
  14. package/dist/index.js +2064 -0
  15. package/dist/keys.d.ts +27 -0
  16. package/dist/pass0.d.ts +19 -0
  17. package/dist/patch/project.d.ts +20 -0
  18. package/dist/report/aggregate.d.ts +21 -0
  19. package/dist/report/assemble.d.ts +16 -0
  20. package/dist/report/order.d.ts +19 -0
  21. package/dist/report/serialize.d.ts +42 -0
  22. package/dist/routing/accepted-assertion.d.ts +12 -0
  23. package/dist/routing/route.d.ts +18 -0
  24. package/dist/routing/target.d.ts +20 -0
  25. package/dist/rules/affects-overlap.d.ts +13 -0
  26. package/dist/rules/affects-resolvable.d.ts +14 -0
  27. package/dist/rules/assertions-compile.d.ts +12 -0
  28. package/dist/rules/assertions-pass.d.ts +13 -0
  29. package/dist/rules/context.d.ts +21 -0
  30. package/dist/rules/decider-resolvable.d.ts +10 -0
  31. package/dist/rules/expiry-sane.d.ts +11 -0
  32. package/dist/rules/id-unique.d.ts +11 -0
  33. package/dist/rules/kernel.d.ts +16 -0
  34. package/dist/rules/no-orphan-refs.d.ts +12 -0
  35. package/dist/rules/schema-valid.d.ts +11 -0
  36. package/dist/rules/scope-hierarchy.d.ts +14 -0
  37. package/dist/rules/supersession-consistent.d.ts +12 -0
  38. package/dist/targets/canonical.d.ts +37 -0
  39. package/dist/targets/package.d.ts +11 -0
  40. package/dist/targets/path.d.ts +10 -0
  41. package/dist/targets/registry.d.ts +11 -0
  42. package/dist/types.d.ts +360 -0
  43. package/package.json +54 -0
  44. package/src/assertions/evaluate.ts +214 -0
  45. package/src/assertions/jsonpath.ts +95 -0
  46. package/src/assertions/limits.ts +57 -0
  47. package/src/assertions/registry.ts +38 -0
  48. package/src/assertions/rego.ts +272 -0
  49. package/src/catalog.ts +263 -0
  50. package/src/compare.ts +13 -0
  51. package/src/crypto/sha256.ts +101 -0
  52. package/src/identity/directory.ts +69 -0
  53. package/src/index.ts +81 -0
  54. package/src/keys.ts +55 -0
  55. package/src/pass0.ts +163 -0
  56. package/src/patch/project.ts +51 -0
  57. package/src/report/aggregate.ts +59 -0
  58. package/src/report/assemble.ts +43 -0
  59. package/src/report/order.ts +53 -0
  60. package/src/report/serialize.ts +152 -0
  61. package/src/routing/accepted-assertion.ts +39 -0
  62. package/src/routing/route.ts +105 -0
  63. package/src/routing/target.ts +104 -0
  64. package/src/rules/affects-overlap.ts +55 -0
  65. package/src/rules/affects-resolvable.ts +83 -0
  66. package/src/rules/assertions-compile.ts +18 -0
  67. package/src/rules/assertions-pass.ts +21 -0
  68. package/src/rules/context.ts +31 -0
  69. package/src/rules/decider-resolvable.ts +55 -0
  70. package/src/rules/expiry-sane.ts +30 -0
  71. package/src/rules/id-unique.ts +57 -0
  72. package/src/rules/kernel.ts +33 -0
  73. package/src/rules/no-orphan-refs.ts +102 -0
  74. package/src/rules/schema-valid.ts +49 -0
  75. package/src/rules/scope-hierarchy.ts +108 -0
  76. package/src/rules/supersession-consistent.ts +138 -0
  77. package/src/targets/canonical.ts +114 -0
  78. package/src/targets/package.ts +41 -0
  79. package/src/targets/path.ts +32 -0
  80. package/src/targets/registry.ts +23 -0
  81. package/src/types.ts +445 -0
package/src/pass0.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @adrkit/evaluator — Pass 0 orchestrator.
3
+ *
4
+ * Pure, total function of `input` (FR-006): same input ⇒ same typed input error or
5
+ * byte-identical report + patch. It performs NO clock/network/db/filesystem access,
6
+ * mutates nothing, imports no adapter/model, and routes without approving or
7
+ * persisting anything. Missing backing degrades to inert, never a thrown error or a
8
+ * fabricated pass/fail.
9
+ *
10
+ * Behaviour is built up across the user stories: the schema-invalid short-circuit and
11
+ * the input-contract branch (T021), the structural rules (US1), the externally-backed
12
+ * rules (US3), routing (US4), and patch projection (US5).
13
+ */
14
+
15
+ import type { Adr, Finding } from '@adrkit/core';
16
+ import { RULE_IDS, type RuleId } from './catalog.ts';
17
+ import { notEvaluated } from './rules/kernel.ts';
18
+ import { acceptedRecordsExcludingCandidate, type RuleContext } from './rules/context.ts';
19
+ import { evaluateSchemaValid } from './rules/schema-valid.ts';
20
+ import { evaluateIdUnique } from './rules/id-unique.ts';
21
+ import { evaluateSupersessionConsistent } from './rules/supersession-consistent.ts';
22
+ import { evaluateNoOrphanRefs } from './rules/no-orphan-refs.ts';
23
+ import { evaluateAffectsResolvable } from './rules/affects-resolvable.ts';
24
+ import { evaluateAffectsOverlap } from './rules/affects-overlap.ts';
25
+ import { evaluateScopeHierarchy } from './rules/scope-hierarchy.ts';
26
+ import { evaluateAssertionsCompile } from './rules/assertions-compile.ts';
27
+ import { evaluateAssertionsPass } from './rules/assertions-pass.ts';
28
+ import { computeAssertionOutcomes } from './assertions/evaluate.ts';
29
+ import { evaluateDeciderResolvable } from './rules/decider-resolvable.ts';
30
+ import { evaluateExpirySane } from './rules/expiry-sane.ts';
31
+ import { assembleReport } from './report/assemble.ts';
32
+ import { computeRouting, notRequiredRouting } from './routing/route.ts';
33
+ import { contradictsAcceptedAdr } from './routing/accepted-assertion.ts';
34
+ import { resolveRecordTargets } from './targets/canonical.ts';
35
+ import { projectPatch } from './patch/project.ts';
36
+ import type {
37
+ Pass0Evaluation,
38
+ Pass0Input,
39
+ Pass0InputContractError,
40
+ ProposalResolution,
41
+ RuleResult,
42
+ } from './types.ts';
43
+
44
+ const PROPOSAL_STATUSES: ReadonlySet<string> = new Set(['draft', 'proposed']);
45
+ const NON_PROPOSAL_STATUSES: ReadonlySet<string> = new Set([
46
+ 'accepted',
47
+ 'rejected',
48
+ 'superseded',
49
+ 'deprecated',
50
+ ]);
51
+
52
+ /** Locate + type the proposal within the corpus (data-model §3). */
53
+ function resolveProposal(input: Pass0Input): ProposalResolution {
54
+ const proposed = input.corpus.records.find((record) => record.path === input.proposalPath);
55
+ if (proposed) {
56
+ return { proposalPath: input.proposalPath, schemaFindings: [], proposed };
57
+ }
58
+ const onPath = input.corpus.findings.filter((finding) => finding.path === input.proposalPath);
59
+ const schemaFindings: readonly Finding[] =
60
+ onPath.length > 0
61
+ ? onPath
62
+ : [
63
+ {
64
+ rule: 'file-read',
65
+ severity: 'error',
66
+ message: `Proposal not found in corpus: ${input.proposalPath}`,
67
+ path: input.proposalPath,
68
+ },
69
+ ];
70
+ return { proposalPath: input.proposalPath, schemaFindings };
71
+ }
72
+
73
+ /** The ten non-schema rules, in fixed order, evaluated over a valid proposal. */
74
+ function evaluateStructuralAndBacked(context: RuleContext): Map<RuleId, RuleResult> {
75
+ const results = new Map<RuleId, RuleResult>();
76
+ results.set('id-unique', evaluateIdUnique(context));
77
+ results.set('supersession-consistent', evaluateSupersessionConsistent(context));
78
+ results.set('no-orphan-refs', evaluateNoOrphanRefs(context));
79
+ results.set('affects-resolvable', evaluateAffectsResolvable(context));
80
+ results.set('affects-overlap', evaluateAffectsOverlap(context));
81
+ results.set('scope-hierarchy', evaluateScopeHierarchy(context));
82
+
83
+ const assertionOutcomes = computeAssertionOutcomes(context);
84
+ const compile = evaluateAssertionsCompile(assertionOutcomes);
85
+ results.set('assertions-compile', compile);
86
+ // Only assertions-pass depends on assertions-compile; a compile failure makes it
87
+ // not-evaluated.prereq-failed (C11). All other rules continue after a non-schema error.
88
+ // The shared `assertionOutcomes` guarantees each assertion is compiled exactly once.
89
+ results.set(
90
+ 'assertions-pass',
91
+ compile.status === 'fail'
92
+ ? notEvaluated('assertions-pass', 'not-evaluated.prereq-failed')
93
+ : evaluateAssertionsPass(assertionOutcomes),
94
+ );
95
+
96
+ results.set('decider-resolvable', evaluateDeciderResolvable(context));
97
+ results.set('expiry-sane', evaluateExpirySane(context));
98
+ return results;
99
+ }
100
+
101
+ /**
102
+ * Evaluate a proposal under Pass 0. Total: returns either a typed input-contract error
103
+ * (no report/patch) or an evaluated `{ report, patch }`.
104
+ */
105
+ export function evaluatePass0(input: Pass0Input): Pass0Evaluation {
106
+ const resolution = resolveProposal(input);
107
+
108
+ // Input-contract precondition: a schema-valid non-draft/proposed record is not a
109
+ // proposal (candidate-status-not-proposal). No report/patch; CLI maps this to exit 2.
110
+ if (resolution.proposed) {
111
+ const status = resolution.proposed.frontmatter.status;
112
+ if (!PROPOSAL_STATUSES.has(status) && NON_PROPOSAL_STATUSES.has(status)) {
113
+ const error: Pass0InputContractError = {
114
+ code: 'candidate-status-not-proposal',
115
+ proposalPath: input.proposalPath,
116
+ actualStatus: status as Pass0InputContractError['actualStatus'],
117
+ };
118
+ return { kind: 'input-error', error };
119
+ }
120
+ }
121
+
122
+ const schemaValid = evaluateSchemaValid(resolution);
123
+ const resultsByRule = new Map<RuleId, RuleResult>();
124
+ resultsByRule.set('schema-valid', schemaValid);
125
+
126
+ if (schemaValid.status === 'fail' || !resolution.proposed) {
127
+ // Schema-invalid short-circuit: schema-valid fail + ten not-evaluated (C11).
128
+ for (const rule of RULE_IDS) {
129
+ if (rule === 'schema-valid') continue;
130
+ resultsByRule.set(rule, notEvaluated(rule, 'not-evaluated.schema-invalid'));
131
+ }
132
+ const report = assembleReport(input.proposalPath, resultsByRule, notRequiredRouting());
133
+ return { kind: 'evaluated', result: { report, patch: projectPatch(report) } };
134
+ }
135
+
136
+ const proposed: Adr = resolution.proposed;
137
+ const context: RuleContext = {
138
+ input,
139
+ proposed,
140
+ resolution,
141
+ corpusRecords: input.corpus.records,
142
+ acceptedRecords: acceptedRecordsExcludingCandidate(input.corpus.records, input.proposalPath),
143
+ evaluationDate: input.evaluationDate,
144
+ };
145
+
146
+ for (const [rule, result] of evaluateStructuralAndBacked(context)) {
147
+ resultsByRule.set(rule, result);
148
+ }
149
+
150
+ // Routing is computed after the eleven rules (R10). The proposal's canonical target
151
+ // set is resolved once and shared by the trigger checks and named-human resolution.
152
+ const proposalTargets = resolveRecordTargets(
153
+ proposed,
154
+ input.targetRegistry,
155
+ input.targets,
156
+ input.resolutionLog,
157
+ );
158
+ const contradicts = contradictsAcceptedAdr(context, proposalTargets.targetKeys);
159
+ const routing = computeRouting(context, proposalTargets, contradicts);
160
+
161
+ const report = assembleReport(input.proposalPath, resultsByRule, routing);
162
+ return { kind: 'evaluated', result: { report, patch: projectPatch(report) } };
163
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @adrkit/evaluator — schema-compatible patch projection (§9, R8/R12, T052).
3
+ *
4
+ * Projects the eleven rule results down to the committed four-field
5
+ * `DeterministicFinding` shape — VIOLATIONS ONLY (rule failures at their fixed
6
+ * severity) — plus `escalate` and existing-enum `escalationReasons` in fixed trigger
7
+ * order. It strips every operational-only field (reason codes, canonical target ids,
8
+ * source refs, snapshot ids, candidate/related refs, recordPath, lower-level evidence):
9
+ * that richness stays on `Pass0Report`. `adr` is copied only when it validates strictly
10
+ * as a core `AdrRef`, so a filesystem path can never be reinterpreted as an ADR
11
+ * reference. The evaluator RETURNS the patch; it never writes any record, review state,
12
+ * database, or index.
13
+ */
14
+
15
+ import { AdrRef, type DeterministicFinding } from '@adrkit/core';
16
+ import { RULE_SEVERITY } from '../catalog.ts';
17
+ import type { EvaluationPatch, Pass0Report, RuleFinding, RuleResult } from '../types.ts';
18
+
19
+ /** A rule result is a violation iff it failed at its fixed severity. */
20
+ export function isViolation(result: RuleResult): boolean {
21
+ return result.status === 'fail';
22
+ }
23
+
24
+ function representativeFinding(result: RuleResult): RuleFinding | undefined {
25
+ // Prefer the finding carrying the aggregate's primary (winning) reason; fall back to
26
+ // the first sorted finding. Both are deterministic.
27
+ return result.findings.find((finding) => finding.reason === result.reason) ?? result.findings[0];
28
+ }
29
+
30
+ /** Project one violating rule result into the committed DeterministicFinding shape. */
31
+ export function projectFinding(result: RuleResult): DeterministicFinding {
32
+ const finding = representativeFinding(result);
33
+ const adr = finding?.adr !== undefined && AdrRef.safeParse(finding.adr).success ? finding.adr : undefined;
34
+ return {
35
+ rule: result.rule,
36
+ severity: RULE_SEVERITY[result.rule],
37
+ ...(finding?.message ? { message: finding.message } : {}),
38
+ ...(adr !== undefined ? { adr } : {}),
39
+ };
40
+ }
41
+
42
+ export function projectPatch(report: Pass0Report): EvaluationPatch {
43
+ // One DeterministicFinding per violating rule (rubric-id mapping, no duplicate
44
+ // lower-level findings). Results are already in fixed rubric order.
45
+ const deterministicFindings = report.results.filter(isViolation).map(projectFinding);
46
+ return {
47
+ deterministicFindings,
48
+ escalate: report.routing.escalate,
49
+ escalationReasons: report.routing.reasons, // already in fixed trigger order
50
+ };
51
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @adrkit/evaluator — deterministic finding aggregation (T026, C11).
3
+ *
4
+ * A rule may make several observations but yields exactly one aggregate `RuleResult`.
5
+ * Status precedence is `fail > inert > pass`; `not-evaluated` is set only by the
6
+ * orchestrator (schema-invalid or `assertions-pass` after a compile failure). The
7
+ * aggregate `reason` is the first code, in the rule's fixed catalog precedence, among
8
+ * the winning-status sub-findings — so mixed fail/inert fixtures are byte-stable
9
+ * rather than discovery-order dependent. All sub-findings are retained and sorted by
10
+ * the stable secondary comparator.
11
+ */
12
+
13
+ import type { Severity } from '@adrkit/core';
14
+ import { RULE_REASON_PRECEDENCE, RULE_SEVERITY, type ReasonCode, type RuleId } from '../catalog.ts';
15
+ import type { RuleEvidence, RuleFinding, RuleResult } from '../types.ts';
16
+ import { sortRuleFindings } from './order.ts';
17
+
18
+ /** One underlying observation a rule makes; aggregated into a single RuleResult. */
19
+ export interface SubResult {
20
+ readonly status: 'pass' | 'fail' | 'inert';
21
+ readonly reason: ReasonCode;
22
+ readonly finding?: RuleFinding;
23
+ }
24
+
25
+ const STATUS_RANK: Record<'pass' | 'fail' | 'inert', number> = { fail: 3, inert: 2, pass: 1 };
26
+
27
+ function winningStatus(subs: readonly SubResult[]): 'pass' | 'fail' | 'inert' {
28
+ let winner: 'pass' | 'fail' | 'inert' = 'pass';
29
+ for (const sub of subs) {
30
+ if (STATUS_RANK[sub.status] > STATUS_RANK[winner]) winner = sub.status;
31
+ }
32
+ return winner;
33
+ }
34
+
35
+ function primaryReason(rule: RuleId, winners: readonly SubResult[]): ReasonCode {
36
+ const present = new Set<ReasonCode>(winners.map((sub) => sub.reason));
37
+ for (const code of RULE_REASON_PRECEDENCE[rule]) {
38
+ if (present.has(code)) return code;
39
+ }
40
+ return RULE_REASON_PRECEDENCE[rule][0] as ReasonCode;
41
+ }
42
+
43
+ /** Aggregate one rule's sub-results into its single RuleResult (findings sorted). */
44
+ export function aggregate(rule: RuleId, subs: readonly SubResult[], evidence?: RuleEvidence): RuleResult {
45
+ const status: 'pass' | 'fail' | 'inert' = subs.length === 0 ? 'pass' : winningStatus(subs);
46
+ const winners = subs.filter((sub) => sub.status === status);
47
+ const reason =
48
+ winners.length === 0 ? (RULE_REASON_PRECEDENCE[rule][0] as ReasonCode) : primaryReason(rule, winners);
49
+ const findings = sortRuleFindings(subs.flatMap((sub) => (sub.finding ? [sub.finding] : [])));
50
+ const severity: Severity | undefined = status === 'fail' ? RULE_SEVERITY[rule] : undefined;
51
+ return {
52
+ rule,
53
+ status,
54
+ ...(severity ? { severity } : {}),
55
+ reason,
56
+ findings,
57
+ ...(evidence ? { evidence } : {}),
58
+ };
59
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @adrkit/evaluator — report assembly.
3
+ *
4
+ * Places the eleven RuleResults in fixed rubric order, appends the routing decision
5
+ * (never a twelfth rule), and computes `outcome`. Secondary finding ordering and
6
+ * canonical byte serialization are added in US2 (T027/T028).
7
+ */
8
+
9
+ import { RULE_IDS, RUBRIC_VERSION, type RuleId } from '../catalog.ts';
10
+ import type { Pass0Report, RoutingDecision, RuleResult } from '../types.ts';
11
+
12
+ /** `returned` iff any rule failed at `error` severity; otherwise `ok`. */
13
+ export function computeOutcome(results: readonly RuleResult[]): 'ok' | 'returned' {
14
+ const returned = results.some(
15
+ (result) => result.status === 'fail' && result.severity === 'error',
16
+ );
17
+ return returned ? 'returned' : 'ok';
18
+ }
19
+
20
+ /**
21
+ * Assemble the report from a rule→result map. Exactly eleven results are emitted in
22
+ * fixed rubric order (C11); a missing rule is a programming error.
23
+ */
24
+ export function assembleReport(
25
+ proposalPath: string,
26
+ resultsByRule: ReadonlyMap<RuleId, RuleResult>,
27
+ routing: RoutingDecision,
28
+ ): Pass0Report {
29
+ const results: RuleResult[] = RULE_IDS.map((rule) => {
30
+ const result = resultsByRule.get(rule);
31
+ if (!result) {
32
+ throw new Error(`internal: missing rule result for "${rule}"`);
33
+ }
34
+ return result;
35
+ });
36
+ return {
37
+ rubricVersion: RUBRIC_VERSION,
38
+ proposalPath,
39
+ results,
40
+ routing,
41
+ outcome: computeOutcome(results),
42
+ };
43
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @adrkit/evaluator — deterministic ordering (R11).
3
+ *
4
+ * Stable secondary comparators for `RuleFinding`s within a rule and for the patch's
5
+ * `deterministicFindings`. Precedence (data-model §5/§7): candidate `AdrRef`, related
6
+ * `AdrRef`, matcher/assertion key, canonical target key, `recordPath`, `field`,
7
+ * `message`. `RuleFinding.adr` is strictly an `AdrRef`; a path never enters this order
8
+ * as `adr`. The eleven rule results keep fixed rubric order and routing keeps trigger
9
+ * order — those arrays are never sorted.
10
+ */
11
+
12
+ import { RULE_IDS, type RuleId } from '../catalog.ts';
13
+ import { canonicalTargetKey } from '../keys.ts';
14
+ import { byCodeUnit } from '../compare.ts';
15
+ import type { CanonicalTargetId, RuleFinding } from '../types.ts';
16
+
17
+ function cmp(a: string | undefined, b: string | undefined): number {
18
+ return byCodeUnit(a ?? '', b ?? '');
19
+ }
20
+
21
+ function targetKey(target: CanonicalTargetId | undefined): string {
22
+ return target ? canonicalTargetKey(target) : '';
23
+ }
24
+
25
+ /** Stable secondary comparator for RuleFindings within a single rule. */
26
+ export function compareRuleFindings(a: RuleFinding, b: RuleFinding): number {
27
+ return (
28
+ cmp(a.candidateAdr, b.candidateAdr) ||
29
+ cmp(a.relatedAdr, b.relatedAdr) ||
30
+ cmp(a.matcherKey ?? a.assertionKey, b.matcherKey ?? b.assertionKey) ||
31
+ cmp(targetKey(a.target), targetKey(b.target)) ||
32
+ cmp(a.recordPath, b.recordPath) ||
33
+ cmp(a.field, b.field) ||
34
+ cmp(a.adr, b.adr) ||
35
+ cmp(a.message, b.message)
36
+ );
37
+ }
38
+
39
+ export function sortRuleFindings(findings: readonly RuleFinding[]): RuleFinding[] {
40
+ return [...findings].sort(compareRuleFindings);
41
+ }
42
+
43
+ const RULE_INDEX: ReadonlyMap<RuleId, number> = new Map(RULE_IDS.map((rule, index) => [rule, index]));
44
+
45
+ /** Index of a rule in the fixed rubric order (for assembly). */
46
+ export function ruleIndex(rule: RuleId): number {
47
+ return RULE_INDEX.get(rule) ?? RULE_IDS.length;
48
+ }
49
+
50
+ /** Comparator for a set-like array of canonical target ids (evidence). */
51
+ export function compareCanonicalTargetIds(a: CanonicalTargetId, b: CanonicalTargetId): number {
52
+ return byCodeUnit(canonicalTargetKey(a), canonicalTargetKey(b));
53
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @adrkit/evaluator — canonical serialization (T028, R11).
3
+ *
4
+ * Produces byte-reproducible `report` and `patch` bytes. Serialization is a hand-written
5
+ * ITERATIVE emitter (no recursion, no `JSON.stringify` key reordering) so that:
6
+ * - object keys are emitted in strict code-unit order — including integer-like keys
7
+ * (`{"2":..,"10":..}` ⇒ `{"10":..,"2":..}`), which `JSON.stringify` would reorder
8
+ * numerically (finding #7);
9
+ * - EVERY own JSON key is retained and emitted, including `__proto__`/`constructor`
10
+ * smuggled through a null-prototype parse (finding #1); and
11
+ * - hostile deep input cannot overflow the stack (finding #5) — the emitter uses an
12
+ * explicit work stack, and untrusted data is depth/node-bounded by `withinJsonLimits`
13
+ * before it ever reaches here.
14
+ *
15
+ * The deterministic payload carries NO timestamp, run id, or duration — caller
16
+ * `runMetadata` lives in the envelope, outside these bytes (FR-005).
17
+ */
18
+
19
+ import { byCodeUnit } from '../compare.ts';
20
+ import type { EvaluationPatch, Pass0Report } from '../types.ts';
21
+
22
+ type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
23
+
24
+ type EmitTask = { readonly kind: 'str'; readonly text: string } | { readonly kind: 'val'; readonly value: unknown; readonly depth: number };
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ function indentOf(depth: number, pretty: boolean): string {
31
+ return pretty ? ' '.repeat(depth) : '';
32
+ }
33
+
34
+ /**
35
+ * Canonical JSON string with keys in code-unit order. `pretty` matches
36
+ * `JSON.stringify(x, null, 2)` formatting exactly (2-space indent, `": "`, LF).
37
+ */
38
+ export function canonicalStringify(root: unknown, pretty = false): string {
39
+ const nl = pretty ? '\n' : '';
40
+ const colon = pretty ? ': ' : ':';
41
+ const out: string[] = [];
42
+ const stack: EmitTask[] = [{ kind: 'val', value: root, depth: 0 }];
43
+
44
+ while (stack.length > 0) {
45
+ const task = stack.pop();
46
+ if (!task) continue;
47
+ if (task.kind === 'str') {
48
+ out.push(task.text);
49
+ continue;
50
+ }
51
+ const { value, depth } = task;
52
+ if (value === null) {
53
+ out.push('null');
54
+ continue;
55
+ }
56
+ const type = typeof value;
57
+ if (type === 'string' || type === 'boolean') {
58
+ out.push(JSON.stringify(value));
59
+ continue;
60
+ }
61
+ if (type === 'number') {
62
+ // Only finite numbers are valid JSON; a non-finite slips through as null.
63
+ out.push(Number.isFinite(value) ? JSON.stringify(value) : 'null');
64
+ continue;
65
+ }
66
+ if (Array.isArray(value)) {
67
+ if (value.length === 0) {
68
+ out.push('[]');
69
+ continue;
70
+ }
71
+ const parts: EmitTask[] = [{ kind: 'str', text: `[${nl}` }];
72
+ value.forEach((item, i) => {
73
+ parts.push({ kind: 'str', text: indentOf(depth + 1, pretty) });
74
+ parts.push({ kind: 'val', value: item, depth: depth + 1 });
75
+ parts.push({ kind: 'str', text: `${i < value.length - 1 ? ',' : ''}${nl}` });
76
+ });
77
+ parts.push({ kind: 'str', text: `${indentOf(depth, pretty)}]` });
78
+ for (let i = parts.length - 1; i >= 0; i -= 1) stack.push(parts[i] as EmitTask);
79
+ continue;
80
+ }
81
+ if (isRecord(value)) {
82
+ const source = value;
83
+ // Own enumerable keys (null-prototype dictionaries keep __proto__/constructor as
84
+ // own keys), sorted deterministically by code unit; undefined values are omitted.
85
+ const keys = Object.keys(source)
86
+ .filter((key) => source[key] !== undefined)
87
+ .sort(byCodeUnit);
88
+ if (keys.length === 0) {
89
+ out.push('{}');
90
+ continue;
91
+ }
92
+ const parts: EmitTask[] = [{ kind: 'str', text: `{${nl}` }];
93
+ keys.forEach((key, i) => {
94
+ parts.push({ kind: 'str', text: `${indentOf(depth + 1, pretty)}${JSON.stringify(key)}${colon}` });
95
+ parts.push({ kind: 'val', value: source[key], depth: depth + 1 });
96
+ parts.push({ kind: 'str', text: `${i < keys.length - 1 ? ',' : ''}${nl}` });
97
+ });
98
+ parts.push({ kind: 'str', text: `${indentOf(depth, pretty)}}` });
99
+ for (let i = parts.length - 1; i >= 0; i -= 1) stack.push(parts[i] as EmitTask);
100
+ continue;
101
+ }
102
+ // Non-JSON (function/symbol/undefined) — defensively emit null. Validated inputs
103
+ // never reach this branch.
104
+ out.push('null');
105
+ }
106
+ return out.join('');
107
+ }
108
+
109
+ /**
110
+ * Canonical JS structure with keys recursively sorted (code unit) — used for structural
111
+ * equality comparisons and CLI envelope building, NOT for byte emission. Output objects
112
+ * are null-prototype so a smuggled `__proto__` own key is retained rather than dropped.
113
+ */
114
+ export function canonicalize(value: unknown): Json {
115
+ if (value === null) return null;
116
+ if (typeof value === 'string' || typeof value === 'boolean') return value;
117
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
118
+ if (Array.isArray(value)) {
119
+ return value.map((item) => canonicalize(item));
120
+ }
121
+ if (!isRecord(value)) return null;
122
+ const source = value;
123
+ const out: { [key: string]: Json } = Object.create(null) as { [key: string]: Json };
124
+ for (const key of Object.keys(source).sort(byCodeUnit)) {
125
+ const entry = source[key];
126
+ if (entry === undefined) continue;
127
+ out[key] = canonicalize(entry);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** Canonical JSON bytes: code-unit-sorted keys, 2-space indent, single trailing LF. */
133
+ export function canonicalBytes(value: unknown): string {
134
+ return `${canonicalStringify(value, true)}\n`;
135
+ }
136
+
137
+ export function serializeReport(report: Pass0Report): string {
138
+ return canonicalBytes(report);
139
+ }
140
+
141
+ export function serializePatch(patch: EvaluationPatch): string {
142
+ return canonicalBytes(patch);
143
+ }
144
+
145
+ export interface CanonicalArtifacts {
146
+ readonly report: string;
147
+ readonly patch: string;
148
+ }
149
+
150
+ export function serializeArtifacts(report: Pass0Report, patch: EvaluationPatch): CanonicalArtifacts {
151
+ return { report: serializeReport(report), patch: serializePatch(patch) };
152
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @adrkit/evaluator — accepted-ADR assertion routing evidence (T048, C4).
3
+ *
4
+ * The `contradicts-accepted-adr` trigger is proven when the proposal canonically
5
+ * overlaps an accepted ADR (rule 6) AND an assertion on that overlapping accepted ADR
6
+ * FAILS against the supplied proposed / current-HEAD input. Unlike `scope-hierarchy`,
7
+ * this does NOT require org scope, domain applicability, or a base-green transition —
8
+ * only the proposed-side failure. Pure; reuses the same engine registry.
9
+ */
10
+
11
+ import { compileAssertionForScope } from '../assertions/evaluate.ts';
12
+ import { makeAssertionKey } from '../keys.ts';
13
+ import { resolveRecordTargets } from '../targets/canonical.ts';
14
+ import type { RuleContext } from '../rules/context.ts';
15
+ import type { CanonicalTargetKey } from '../types.ts';
16
+
17
+ export function contradictsAcceptedAdr(
18
+ ctx: RuleContext,
19
+ proposalTargetKeys: ReadonlySet<CanonicalTargetKey>,
20
+ ): boolean {
21
+ if (proposalTargetKeys.size === 0) return false;
22
+
23
+ for (const accepted of ctx.acceptedRecords) {
24
+ const acceptedTargets = resolveRecordTargets(accepted, ctx.input.targetRegistry, ctx.input.targets, ctx.input.resolutionLog);
25
+ const overlaps = [...proposalTargetKeys].some((key) => acceptedTargets.targetKeys.has(key));
26
+ if (!overlaps) continue;
27
+
28
+ for (const assertion of accepted.frontmatter.assertions) {
29
+ const key = makeAssertionKey(accepted.log, accepted.path, assertion.id);
30
+ const compiled = compileAssertionForScope(ctx, accepted, assertion);
31
+ if (!compiled.ok) continue;
32
+ const proposedInput = ctx.input.assertionInputs.inputs[key]?.document;
33
+ if (proposedInput === undefined) continue;
34
+ const evaluated = compiled.evaluate(proposedInput);
35
+ if (evaluated.ok && !evaluated.pass) return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @adrkit/evaluator — escalation routing (T046, R10/C4/C7).
3
+ *
4
+ * A declarative OR over the eight deterministically-proven Pass 0 triggers, computed
5
+ * AFTER the eleven rule results (never a twelfth rule). Each trigger emits an ordered
6
+ * proven/not-proven evidence status; missing optional evidence is "not-proven", never a
7
+ * fabricated escalation. When escalation is proven, a single active human is resolved
8
+ * (deciders -> CODEOWNERS -> catalog); otherwise the target is `not-required`.
9
+ */
10
+
11
+ import {
12
+ ROUTE_ESCALATE_CODE,
13
+ ROUTE_EVIDENCE_NOT_PROVEN_CODE,
14
+ ROUTING_TRIGGERS,
15
+ type Pass0EscalationReason,
16
+ } from '../catalog.ts';
17
+ import { buildIdentityIndex } from '../identity/directory.ts';
18
+ import type { RuleContext } from '../rules/context.ts';
19
+ import type { RecordTargetResolution } from '../targets/canonical.ts';
20
+ import { resolveRouteTarget } from './target.ts';
21
+ import type { CanonicalTargetKey, RoutingDecision, TriggerEvidenceStatus } from '../types.ts';
22
+
23
+ /** All eight triggers as `not-proven`, in fixed order (used for schema-invalid reports). */
24
+ export function allNotProven(): readonly TriggerEvidenceStatus[] {
25
+ return ROUTING_TRIGGERS.map(
26
+ (reason: Pass0EscalationReason): TriggerEvidenceStatus => ({
27
+ reason,
28
+ status: 'not-proven',
29
+ code: ROUTE_EVIDENCE_NOT_PROVEN_CODE[reason],
30
+ }),
31
+ );
32
+ }
33
+
34
+ /** The deterministic non-escalated routing decision (schema-invalid short-circuit). */
35
+ export function notRequiredRouting(): RoutingDecision {
36
+ return {
37
+ escalate: false,
38
+ reasons: [],
39
+ evidenceStatus: allNotProven(),
40
+ target: { kind: 'not-required', code: 'route.target.not-required' },
41
+ };
42
+ }
43
+
44
+ function intersects(a: ReadonlySet<CanonicalTargetKey>, b: ReadonlySet<CanonicalTargetKey> | undefined): boolean {
45
+ if (!b || b.size === 0) return false;
46
+ for (const key of a) if (b.has(key)) return true;
47
+ return false;
48
+ }
49
+
50
+ /** Evaluate the eight triggers and resolve the target if escalation is proven. */
51
+ export function computeRouting(
52
+ ctx: RuleContext,
53
+ proposalTargets: RecordTargetResolution,
54
+ contradictsAccepted: boolean,
55
+ ): RoutingDecision {
56
+ const evidence = ctx.input.routingEvidence;
57
+ const proposalKeys = proposalTargets.targetKeys;
58
+ const frontmatter = ctx.proposed.frontmatter;
59
+
60
+ const proven: Record<Pass0EscalationReason, boolean> = {
61
+ 'one-way-door': frontmatter.reversibility === 'one-way-door',
62
+ 'cost-threshold':
63
+ evidence?.costEvidence !== undefined && evidence.costEvidence.normalizedCost >= evidence.costEvidence.threshold,
64
+ 'security-surface': intersects(proposalKeys, evidence?.securitySurfaceTargets),
65
+ 'data-residency': evidence?.dataResidency?.present === true,
66
+ regulatory: frontmatter.complianceControls.length > 0 || intersects(proposalKeys, evidence?.regulatedTargets),
67
+ 'contradicts-accepted-adr': contradictsAccepted,
68
+ 'agent-authored-production':
69
+ (frontmatter.provenance?.authoredBy === 'agent' || frontmatter.provenance?.authoredBy === 'agent-drafted') &&
70
+ intersects(proposalKeys, evidence?.productionTargets),
71
+ 'human-requested': evidence?.humanRequested?.requester !== undefined,
72
+ };
73
+
74
+ const evidenceStatus: TriggerEvidenceStatus[] = ROUTING_TRIGGERS.map((reason) =>
75
+ proven[reason]
76
+ ? { reason, status: 'proven', code: ROUTE_ESCALATE_CODE[reason] }
77
+ : { reason, status: 'not-proven', code: ROUTE_EVIDENCE_NOT_PROVEN_CODE[reason] },
78
+ );
79
+ const reasons = ROUTING_TRIGGERS.filter((reason) => proven[reason]);
80
+ const escalate = reasons.length > 0;
81
+
82
+ if (!escalate) {
83
+ return {
84
+ escalate: false,
85
+ reasons: [],
86
+ evidenceStatus,
87
+ target: { kind: 'not-required', code: 'route.target.not-required' },
88
+ };
89
+ }
90
+
91
+ const directory = ctx.input.identity;
92
+ if (!directory) {
93
+ return { escalate: true, reasons, evidenceStatus, target: { kind: 'unresolved', code: 'route.target.unresolved' } };
94
+ }
95
+ const resolvedPaths = proposalTargets.targets.filter((id) => id.kind === 'path').map((id) => id.id);
96
+ const resolvedEntities = proposalTargets.targets.filter((id) => id.kind === 'entity');
97
+ const target = resolveRouteTarget(
98
+ buildIdentityIndex(directory),
99
+ frontmatter.deciders,
100
+ directory,
101
+ resolvedPaths,
102
+ resolvedEntities,
103
+ );
104
+ return { escalate: true, reasons, evidenceStatus, target };
105
+ }