@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.
- package/README.md +23 -0
- package/dist/LICENSE +201 -0
- package/dist/NOTICE +11 -0
- package/dist/assertions/evaluate.d.ts +36 -0
- package/dist/assertions/jsonpath.d.ts +20 -0
- package/dist/assertions/limits.d.ts +21 -0
- package/dist/assertions/registry.d.ts +20 -0
- package/dist/assertions/rego.d.ts +27 -0
- package/dist/catalog.d.ts +39 -0
- package/dist/compare.d.ts +10 -0
- package/dist/crypto/sha256.d.ts +10 -0
- package/dist/identity/directory.d.ts +23 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +2064 -0
- package/dist/keys.d.ts +27 -0
- package/dist/pass0.d.ts +19 -0
- package/dist/patch/project.d.ts +20 -0
- package/dist/report/aggregate.d.ts +21 -0
- package/dist/report/assemble.d.ts +16 -0
- package/dist/report/order.d.ts +19 -0
- package/dist/report/serialize.d.ts +42 -0
- package/dist/routing/accepted-assertion.d.ts +12 -0
- package/dist/routing/route.d.ts +18 -0
- package/dist/routing/target.d.ts +20 -0
- package/dist/rules/affects-overlap.d.ts +13 -0
- package/dist/rules/affects-resolvable.d.ts +14 -0
- package/dist/rules/assertions-compile.d.ts +12 -0
- package/dist/rules/assertions-pass.d.ts +13 -0
- package/dist/rules/context.d.ts +21 -0
- package/dist/rules/decider-resolvable.d.ts +10 -0
- package/dist/rules/expiry-sane.d.ts +11 -0
- package/dist/rules/id-unique.d.ts +11 -0
- package/dist/rules/kernel.d.ts +16 -0
- package/dist/rules/no-orphan-refs.d.ts +12 -0
- package/dist/rules/schema-valid.d.ts +11 -0
- package/dist/rules/scope-hierarchy.d.ts +14 -0
- package/dist/rules/supersession-consistent.d.ts +12 -0
- package/dist/targets/canonical.d.ts +37 -0
- package/dist/targets/package.d.ts +11 -0
- package/dist/targets/path.d.ts +10 -0
- package/dist/targets/registry.d.ts +11 -0
- package/dist/types.d.ts +360 -0
- package/package.json +54 -0
- package/src/assertions/evaluate.ts +214 -0
- package/src/assertions/jsonpath.ts +95 -0
- package/src/assertions/limits.ts +57 -0
- package/src/assertions/registry.ts +38 -0
- package/src/assertions/rego.ts +272 -0
- package/src/catalog.ts +263 -0
- package/src/compare.ts +13 -0
- package/src/crypto/sha256.ts +101 -0
- package/src/identity/directory.ts +69 -0
- package/src/index.ts +81 -0
- package/src/keys.ts +55 -0
- package/src/pass0.ts +163 -0
- package/src/patch/project.ts +51 -0
- package/src/report/aggregate.ts +59 -0
- package/src/report/assemble.ts +43 -0
- package/src/report/order.ts +53 -0
- package/src/report/serialize.ts +152 -0
- package/src/routing/accepted-assertion.ts +39 -0
- package/src/routing/route.ts +105 -0
- package/src/routing/target.ts +104 -0
- package/src/rules/affects-overlap.ts +55 -0
- package/src/rules/affects-resolvable.ts +83 -0
- package/src/rules/assertions-compile.ts +18 -0
- package/src/rules/assertions-pass.ts +21 -0
- package/src/rules/context.ts +31 -0
- package/src/rules/decider-resolvable.ts +55 -0
- package/src/rules/expiry-sane.ts +30 -0
- package/src/rules/id-unique.ts +57 -0
- package/src/rules/kernel.ts +33 -0
- package/src/rules/no-orphan-refs.ts +102 -0
- package/src/rules/schema-valid.ts +49 -0
- package/src/rules/scope-hierarchy.ts +108 -0
- package/src/rules/supersession-consistent.ts +138 -0
- package/src/targets/canonical.ts +114 -0
- package/src/targets/package.ts +41 -0
- package/src/targets/path.ts +32 -0
- package/src/targets/registry.ts +23 -0
- package/src/types.ts +445 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — named-human routing target (T047, R9/C7).
|
|
3
|
+
*
|
|
4
|
+
* Resolves a single active human in fixed source order: (1) proposal `deciders`, (2)
|
|
5
|
+
* CODEOWNERS owners for the proposal's resolved paths, (3) catalog owners for its
|
|
6
|
+
* resolved entities. Exact source-local ordering: deciders in declaration order; unique
|
|
7
|
+
* paths sorted by canonical path key, each taking the LAST matching CODEOWNERS rule's
|
|
8
|
+
* owners in declaration order; unique entities sorted by canonical target key, each
|
|
9
|
+
* appending catalog owners in snapshot order. Candidates are stable-deduplicated at
|
|
10
|
+
* first occurrence (never globally identity-sorted). A missing/inactive direct human is
|
|
11
|
+
* skipped; the first team that does not resolve to exactly one active human is an
|
|
12
|
+
* ambiguity barrier that immediately yields `unresolved`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { matchPathPattern } from '@adrkit/core';
|
|
16
|
+
import { canonicalTargetKey } from '../keys.ts';
|
|
17
|
+
import { byCodeUnit } from '../compare.ts';
|
|
18
|
+
import type { IdentityIndex } from '../identity/directory.ts';
|
|
19
|
+
import type { CanonicalTargetId, IdentityDirectorySnapshot, PrincipalRef, RouteTarget } from '../types.ts';
|
|
20
|
+
|
|
21
|
+
type Via = 'deciders' | 'codeowners' | 'catalog';
|
|
22
|
+
|
|
23
|
+
interface Candidate {
|
|
24
|
+
readonly ref: PrincipalRef;
|
|
25
|
+
readonly via: Via;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const VIA_CODE: Record<Via, 'route.target.deciders' | 'route.target.codeowners' | 'route.target.catalog-owner'> = {
|
|
29
|
+
deciders: 'route.target.deciders',
|
|
30
|
+
codeowners: 'route.target.codeowners',
|
|
31
|
+
catalog: 'route.target.catalog-owner',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function orderedCandidates(
|
|
35
|
+
deciders: readonly PrincipalRef[],
|
|
36
|
+
directory: IdentityDirectorySnapshot,
|
|
37
|
+
resolvedPaths: readonly string[],
|
|
38
|
+
resolvedEntities: readonly CanonicalTargetId[],
|
|
39
|
+
): Candidate[] {
|
|
40
|
+
const candidates: Candidate[] = [];
|
|
41
|
+
|
|
42
|
+
// (1) deciders in declaration order
|
|
43
|
+
for (const decider of deciders) candidates.push({ ref: decider, via: 'deciders' });
|
|
44
|
+
|
|
45
|
+
// (2) CODEOWNERS for unique resolved paths sorted by canonical path key
|
|
46
|
+
const codeowners = directory.codeowners ?? [];
|
|
47
|
+
const uniquePaths = [...new Set(resolvedPaths)].sort(byCodeUnit);
|
|
48
|
+
for (const path of uniquePaths) {
|
|
49
|
+
let lastMatch: readonly PrincipalRef[] | undefined;
|
|
50
|
+
for (const rule of codeowners) {
|
|
51
|
+
if (matchPathPattern(rule.pattern, [path]).matched) lastMatch = rule.owners;
|
|
52
|
+
}
|
|
53
|
+
if (lastMatch) {
|
|
54
|
+
for (const owner of lastMatch) candidates.push({ ref: owner, via: 'codeowners' });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// (3) catalog owners for unique resolved entities sorted by canonical target key
|
|
59
|
+
const catalogOwners = directory.catalogOwners;
|
|
60
|
+
const uniqueEntities = [...new Map(resolvedEntities.map((id) => [canonicalTargetKey(id), id])).values()].sort((a, b) =>
|
|
61
|
+
byCodeUnit(canonicalTargetKey(a), canonicalTargetKey(b)),
|
|
62
|
+
);
|
|
63
|
+
for (const entity of uniqueEntities) {
|
|
64
|
+
const owners =
|
|
65
|
+
catalogOwners !== undefined && Object.hasOwn(catalogOwners, entity.id)
|
|
66
|
+
? catalogOwners[entity.id] ?? []
|
|
67
|
+
: [];
|
|
68
|
+
for (const owner of owners) candidates.push({ ref: owner, via: 'catalog' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return candidates;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the escalation target. Called only when escalation is proven; a non-escalated
|
|
76
|
+
* run uses `route.target.not-required` at the routing layer.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveRouteTarget(
|
|
79
|
+
index: IdentityIndex,
|
|
80
|
+
deciders: readonly PrincipalRef[],
|
|
81
|
+
directory: IdentityDirectorySnapshot,
|
|
82
|
+
resolvedPaths: readonly string[],
|
|
83
|
+
resolvedEntities: readonly CanonicalTargetId[],
|
|
84
|
+
): RouteTarget {
|
|
85
|
+
const candidates = orderedCandidates(deciders, directory, resolvedPaths, resolvedEntities);
|
|
86
|
+
const seen = new Set<PrincipalRef>();
|
|
87
|
+
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
if (seen.has(candidate.ref)) continue; // stable dedupe at first occurrence
|
|
90
|
+
seen.add(candidate.ref);
|
|
91
|
+
|
|
92
|
+
const resolution = index.resolveToActiveHuman(candidate.ref);
|
|
93
|
+
if (resolution.status === 'resolved') {
|
|
94
|
+
return { kind: 'resolved', human: resolution.human, via: candidate.via, code: VIA_CODE[candidate.via] };
|
|
95
|
+
}
|
|
96
|
+
// A team that does not resolve to exactly one active human is an ambiguity barrier.
|
|
97
|
+
if (index.isTeam(candidate.ref)) {
|
|
98
|
+
return { kind: 'unresolved', code: 'route.target.unresolved' };
|
|
99
|
+
}
|
|
100
|
+
// Otherwise a missing/inactive direct human is skipped.
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { kind: 'unresolved', code: 'route.target.unresolved' };
|
|
104
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — rule 6: affects-overlap (warn).
|
|
3
|
+
*
|
|
4
|
+
* Finite canonical-target-key intersection of the proposal with each ACCEPTED ADR,
|
|
5
|
+
* computed once per (proposal, accepted-ADR) pair (R4). Primary precedence (§3.1):
|
|
6
|
+
* any non-empty intersection ⇒ warn (`accepted-intersection`); otherwise no accepted
|
|
7
|
+
* ADRs ⇒ pass (`no-accepted-corpus`); otherwise absent required pair backing ⇒ inert
|
|
8
|
+
* (`backing-absent`); otherwise a fully evaluated accepted corpus with no intersection
|
|
9
|
+
* ⇒ pass (`none`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { aggregate, inertResult, passResult, type SubResult } from './kernel.ts';
|
|
13
|
+
import type { RuleContext } from './context.ts';
|
|
14
|
+
import { anyMatcherInert, resolveRecordTargets } from '../targets/canonical.ts';
|
|
15
|
+
import { byCodeUnit } from '../compare.ts';
|
|
16
|
+
import type { RuleResult } from '../types.ts';
|
|
17
|
+
|
|
18
|
+
export function evaluateAffectsOverlap(ctx: RuleContext): RuleResult {
|
|
19
|
+
const accepted = ctx.acceptedRecords;
|
|
20
|
+
if (accepted.length === 0) {
|
|
21
|
+
return passResult('affects-overlap', 'affects-overlap.no-accepted-corpus');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const proposal = resolveRecordTargets(ctx.proposed, ctx.input.targetRegistry, ctx.input.targets, ctx.input.resolutionLog);
|
|
25
|
+
let anyInert = anyMatcherInert(proposal);
|
|
26
|
+
const subs: SubResult[] = [];
|
|
27
|
+
const overlapping: string[] = [];
|
|
28
|
+
|
|
29
|
+
for (const acc of accepted) {
|
|
30
|
+
const accResolution = resolveRecordTargets(acc, ctx.input.targetRegistry, ctx.input.targets, ctx.input.resolutionLog);
|
|
31
|
+
if (anyMatcherInert(accResolution)) anyInert = true;
|
|
32
|
+
const intersects = [...proposal.targetKeys].some((key) => accResolution.targetKeys.has(key));
|
|
33
|
+
if (intersects) {
|
|
34
|
+
overlapping.push(acc.frontmatter.id);
|
|
35
|
+
subs.push({
|
|
36
|
+
status: 'fail',
|
|
37
|
+
reason: 'affects-overlap.accepted-intersection',
|
|
38
|
+
finding: {
|
|
39
|
+
reason: 'affects-overlap.accepted-intersection',
|
|
40
|
+
candidateAdr: ctx.proposed.frontmatter.id,
|
|
41
|
+
relatedAdr: acc.frontmatter.id,
|
|
42
|
+
message: `affects overlaps accepted ADR "${acc.frontmatter.id}"`,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (subs.length > 0) {
|
|
49
|
+
return aggregate('affects-overlap', subs, { overlappingWith: [...overlapping].sort(byCodeUnit) });
|
|
50
|
+
}
|
|
51
|
+
if (anyInert) {
|
|
52
|
+
return inertResult('affects-overlap', 'affects-overlap.backing-absent');
|
|
53
|
+
}
|
|
54
|
+
return passResult('affects-overlap', 'affects-overlap.none');
|
|
55
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — rule 5: affects-resolvable (warn).
|
|
3
|
+
*
|
|
4
|
+
* Validates EACH proposal matcher independently, not only the union (finding #2): a
|
|
5
|
+
* matcher that resolves to real targets cannot mask another positive matcher with
|
|
6
|
+
* present backing that resolves to ZERO. ADR-0009 semantics are preserved — negation
|
|
7
|
+
* subtracts, and a different-repo qualifier contributes no local match. Per positive
|
|
8
|
+
* matcher: ≥1 resolved id ⇒ pass; present backing + zero ids ⇒ warn (`zero-targets`);
|
|
9
|
+
* a missing inventory (`backing-absent`) or resolver (`resolver-absent`) ⇒ inert. A
|
|
10
|
+
* negation-only record resolves to the empty set ⇒ warn. No affects ⇒ trivially pass.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { aggregate, passResult, type SubResult } from './kernel.ts';
|
|
14
|
+
import type { RuleContext } from './context.ts';
|
|
15
|
+
import { resolveRecordTargets } from '../targets/canonical.ts';
|
|
16
|
+
import type { MatcherResolution } from '../targets/canonical.ts';
|
|
17
|
+
import type { RuleFinding, RuleResult } from '../types.ts';
|
|
18
|
+
|
|
19
|
+
function matcherFinding(ctx: RuleContext, matcher: MatcherResolution, reason: RuleFinding['reason'], message: string): RuleFinding {
|
|
20
|
+
return {
|
|
21
|
+
reason,
|
|
22
|
+
candidateAdr: ctx.proposed.frontmatter.id,
|
|
23
|
+
matcherKey: `${matcher.type}:${matcher.pattern}`,
|
|
24
|
+
recordPath: ctx.proposed.path,
|
|
25
|
+
field: `affects.${matcher.type}`,
|
|
26
|
+
message,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function evaluateAffectsResolvable(ctx: RuleContext): RuleResult {
|
|
31
|
+
const resolution = resolveRecordTargets(ctx.proposed, ctx.input.targetRegistry, ctx.input.targets, ctx.input.resolutionLog);
|
|
32
|
+
if (!resolution.hasMatchers) return passResult('affects-resolvable');
|
|
33
|
+
|
|
34
|
+
const subs: SubResult[] = [];
|
|
35
|
+
|
|
36
|
+
for (const matcher of resolution.matchers) {
|
|
37
|
+
if (matcher.status === 'inert-resolver') {
|
|
38
|
+
subs.push({
|
|
39
|
+
status: 'inert',
|
|
40
|
+
reason: 'affects-resolvable.resolver-absent',
|
|
41
|
+
finding: matcherFinding(ctx, matcher, 'affects-resolvable.resolver-absent', `No resolver registered for "${matcher.type}" matcher "${matcher.pattern}"`),
|
|
42
|
+
});
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (matcher.status === 'inert-backing') {
|
|
46
|
+
subs.push({
|
|
47
|
+
status: 'inert',
|
|
48
|
+
reason: 'affects-resolvable.backing-absent',
|
|
49
|
+
finding: matcherFinding(ctx, matcher, 'affects-resolvable.backing-absent', `No inventory backing for "${matcher.type}" matcher "${matcher.pattern}"`),
|
|
50
|
+
});
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// resolved (including a different-repo matcher, which resolves empty locally)
|
|
54
|
+
if (matcher.negate) continue; // negations only subtract; they are not a zero-target warn
|
|
55
|
+
if (matcher.ids.length >= 1) {
|
|
56
|
+
subs.push({ status: 'pass', reason: 'affects-resolvable.ok' });
|
|
57
|
+
} else {
|
|
58
|
+
subs.push({
|
|
59
|
+
status: 'fail',
|
|
60
|
+
reason: 'affects-resolvable.zero-targets',
|
|
61
|
+
finding: matcherFinding(ctx, matcher, 'affects-resolvable.zero-targets', `"${matcher.type}" matcher "${matcher.pattern}" resolves to zero targets against the supplied inventory`),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A record with matchers but NO positive matcher (negation-only) resolves to empty.
|
|
67
|
+
if (!resolution.hasPositiveMatcher) {
|
|
68
|
+
subs.push({
|
|
69
|
+
status: 'fail',
|
|
70
|
+
reason: 'affects-resolvable.zero-targets',
|
|
71
|
+
finding: {
|
|
72
|
+
reason: 'affects-resolvable.zero-targets',
|
|
73
|
+
candidateAdr: ctx.proposed.frontmatter.id,
|
|
74
|
+
recordPath: ctx.proposed.path,
|
|
75
|
+
field: 'affects',
|
|
76
|
+
message: 'negation-only affects resolves to zero targets',
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (subs.length === 0) return passResult('affects-resolvable');
|
|
82
|
+
return aggregate('affects-resolvable', subs, { resolvedTargets: resolution.targets });
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — rule 8: assertions-compile (error).
|
|
3
|
+
*
|
|
4
|
+
* Each assertion declares exactly one source and the approved engine profile validates
|
|
5
|
+
* it. Neither/both source ⇒ error; a failed source/artifact validation ⇒ error; a
|
|
6
|
+
* missing engine, resolved file content, or compiled artifact ⇒ inert; no assertions ⇒
|
|
7
|
+
* pass (`none`). Consumes the shared per-evaluation assertion outcomes so each assertion
|
|
8
|
+
* is compiled/validated exactly once (R7). One aggregate result.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { aggregate, passResult } from './kernel.ts';
|
|
12
|
+
import type { AssertionOutcomes } from '../assertions/evaluate.ts';
|
|
13
|
+
import type { RuleResult } from '../types.ts';
|
|
14
|
+
|
|
15
|
+
export function evaluateAssertionsCompile(outcomes: AssertionOutcomes): RuleResult {
|
|
16
|
+
if (!outcomes.hasAssertions) return passResult('assertions-compile', 'assertions-compile.none');
|
|
17
|
+
return aggregate('assertions-compile', outcomes.compileSubs);
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — rule 9: assertions-pass (warn).
|
|
3
|
+
*
|
|
4
|
+
* Evaluates each compiled assertion against its resolved input. A false result or a
|
|
5
|
+
* deterministic engine evaluation error ⇒ warn; a missing engine/input ⇒ inert; no
|
|
6
|
+
* assertions ⇒ pass (`none`). A compile FAILURE makes this whole rule
|
|
7
|
+
* `not-evaluated.prereq-failed` — enforced by the orchestrator, which only calls this
|
|
8
|
+
* rule when assertions-compile did not fail. Consumes the shared per-evaluation
|
|
9
|
+
* outcomes (one compile per assertion). One aggregate result.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { aggregate, passResult } from './kernel.ts';
|
|
13
|
+
import type { AssertionOutcomes } from '../assertions/evaluate.ts';
|
|
14
|
+
import type { RuleResult } from '../types.ts';
|
|
15
|
+
|
|
16
|
+
export function evaluateAssertionsPass(outcomes: AssertionOutcomes): RuleResult {
|
|
17
|
+
if (!outcomes.hasAssertions || outcomes.passSubs.length === 0) {
|
|
18
|
+
return passResult('assertions-pass', 'assertions-pass.none');
|
|
19
|
+
}
|
|
20
|
+
return aggregate('assertions-pass', outcomes.passSubs);
|
|
21
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — per-rule evaluation context.
|
|
3
|
+
*
|
|
4
|
+
* Assembled once by the orchestrator after `schema-valid` passes. Rules read only
|
|
5
|
+
* from this immutable context; none performs I/O.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Adr } from '@adrkit/core';
|
|
9
|
+
import type { Pass0Input, ProposalResolution } from '../types.ts';
|
|
10
|
+
|
|
11
|
+
export interface RuleContext {
|
|
12
|
+
readonly input: Pass0Input;
|
|
13
|
+
/** The typed proposal (schema-valid passed). */
|
|
14
|
+
readonly proposed: Adr;
|
|
15
|
+
readonly resolution: ProposalResolution;
|
|
16
|
+
/** Full corpus records, INCLUDING the candidate (data-model §2). */
|
|
17
|
+
readonly corpusRecords: readonly Adr[];
|
|
18
|
+
/** Accepted ADRs in the corpus, EXCLUDING the candidate. */
|
|
19
|
+
readonly acceptedRecords: readonly Adr[];
|
|
20
|
+
readonly evaluationDate: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Records in the corpus that are accepted and are not the candidate proposal. */
|
|
24
|
+
export function acceptedRecordsExcludingCandidate(
|
|
25
|
+
records: readonly Adr[],
|
|
26
|
+
proposalPath: string,
|
|
27
|
+
): readonly Adr[] {
|
|
28
|
+
return records.filter(
|
|
29
|
+
(record) => record.frontmatter.status === 'accepted' && record.path !== proposalPath,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — rule 10: decider-resolvable (warn).
|
|
3
|
+
*
|
|
4
|
+
* Every declared proposal decider must resolve through the immutable identity
|
|
5
|
+
* directory to exactly one active principal (R9). None declared, a zero match, or an
|
|
6
|
+
* ambiguous match is a warn; an absent directory is inert. Exactly one aggregate result.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { aggregate, inertResult, type SubResult } from './kernel.ts';
|
|
10
|
+
import type { RuleContext } from './context.ts';
|
|
11
|
+
import { buildIdentityIndex } from '../identity/directory.ts';
|
|
12
|
+
import type { RuleResult } from '../types.ts';
|
|
13
|
+
|
|
14
|
+
export function evaluateDeciderResolvable(ctx: RuleContext): RuleResult {
|
|
15
|
+
const directory = ctx.input.identity;
|
|
16
|
+
if (!directory) {
|
|
17
|
+
return inertResult('decider-resolvable', 'decider-resolvable.directory-absent');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const deciders = ctx.proposed.frontmatter.deciders;
|
|
21
|
+
if (deciders.length === 0) {
|
|
22
|
+
return aggregate('decider-resolvable', [
|
|
23
|
+
{
|
|
24
|
+
status: 'fail',
|
|
25
|
+
reason: 'decider-resolvable.none-declared',
|
|
26
|
+
finding: {
|
|
27
|
+
reason: 'decider-resolvable.none-declared',
|
|
28
|
+
candidateAdr: ctx.proposed.frontmatter.id,
|
|
29
|
+
field: 'deciders',
|
|
30
|
+
message: 'No deciders are declared on the proposal',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const index = buildIdentityIndex(directory);
|
|
37
|
+
const subs: SubResult[] = deciders.map((decider) => {
|
|
38
|
+
const resolution = index.resolveToActiveHuman(decider);
|
|
39
|
+
if (resolution.status === 'resolved') {
|
|
40
|
+
return { status: 'pass', reason: 'decider-resolvable.ok' };
|
|
41
|
+
}
|
|
42
|
+
const reason = resolution.status === 'ambiguous' ? 'decider-resolvable.ambiguous-match' : 'decider-resolvable.zero-match';
|
|
43
|
+
return {
|
|
44
|
+
status: 'fail',
|
|
45
|
+
reason,
|
|
46
|
+
finding: {
|
|
47
|
+
reason,
|
|
48
|
+
candidateAdr: ctx.proposed.frontmatter.id,
|
|
49
|
+
field: 'deciders',
|
|
50
|
+
message: `Decider "${decider}" ${resolution.status === 'ambiguous' ? 'resolves ambiguously' : 'does not resolve to one active principal'}`,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
return aggregate('decider-resolvable', subs);
|
|
55
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — rule 11: expiry-sane (info).
|
|
3
|
+
*
|
|
4
|
+
* `reviewBy` is compared to the caller-supplied `evaluationDate` — NO clock is read.
|
|
5
|
+
* Absent `reviewBy`, or one strictly after the evaluation date, passes; a `reviewBy`
|
|
6
|
+
* on or before the evaluation date is `info` (`past-or-equal`). Both are ISO `YYYY-MM-DD`
|
|
7
|
+
* strings, which compare correctly lexicographically.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { aggregate, passResult } from './kernel.ts';
|
|
11
|
+
import type { RuleContext } from './context.ts';
|
|
12
|
+
import type { RuleResult } from '../types.ts';
|
|
13
|
+
|
|
14
|
+
export function evaluateExpirySane(ctx: RuleContext): RuleResult {
|
|
15
|
+
const reviewBy = ctx.proposed.frontmatter.reviewBy;
|
|
16
|
+
if (reviewBy === undefined) return passResult('expiry-sane');
|
|
17
|
+
if (reviewBy > ctx.evaluationDate) return passResult('expiry-sane');
|
|
18
|
+
return aggregate('expiry-sane', [
|
|
19
|
+
{
|
|
20
|
+
status: 'fail',
|
|
21
|
+
reason: 'expiry-sane.past-or-equal',
|
|
22
|
+
finding: {
|
|
23
|
+
reason: 'expiry-sane.past-or-equal',
|
|
24
|
+
candidateAdr: ctx.proposed.frontmatter.id,
|
|
25
|
+
field: 'reviewBy',
|
|
26
|
+
message: `reviewBy "${reviewBy}" is on or before the evaluation date "${ctx.evaluationDate}"`,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — rule 2: id-unique (error).
|
|
3
|
+
*
|
|
4
|
+
* Identity is scoped by `[record.log ?? "", id]` over the candidate-inclusive corpus
|
|
5
|
+
* plus optional federated-log snapshots (T018). A duplicate of the candidate's key
|
|
6
|
+
* fails (`id-unique.collision`); equal ids in different named logs pass. Exactly one
|
|
7
|
+
* aggregate result is emitted (C11).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Adr } from '@adrkit/core';
|
|
11
|
+
import { aggregate, passResult, type SubResult } from './kernel.ts';
|
|
12
|
+
import type { RuleContext } from './context.ts';
|
|
13
|
+
import type { FederatedLogSnapshot, RuleFinding, RuleResult } from '../types.ts';
|
|
14
|
+
|
|
15
|
+
function key(log: string | undefined, id: string): string {
|
|
16
|
+
return JSON.stringify([log ?? '', id]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function corpusKey(record: Adr): string {
|
|
20
|
+
return key(record.log, record.frontmatter.id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function federatedKeys(snapshots: readonly FederatedLogSnapshot[] | undefined): string[] {
|
|
24
|
+
const keys: string[] = [];
|
|
25
|
+
for (const snapshot of snapshots ?? []) {
|
|
26
|
+
for (const id of snapshot.adrIds) keys.push(key(snapshot.log, id));
|
|
27
|
+
}
|
|
28
|
+
return keys;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function evaluateIdUnique(ctx: RuleContext): RuleResult {
|
|
32
|
+
const candidate = key(ctx.proposed.log, ctx.proposed.frontmatter.id);
|
|
33
|
+
|
|
34
|
+
let occurrences = 0;
|
|
35
|
+
for (const record of ctx.corpusRecords) {
|
|
36
|
+
if (corpusKey(record) === candidate) occurrences += 1;
|
|
37
|
+
}
|
|
38
|
+
for (const federated of federatedKeys(ctx.input.federatedLogs)) {
|
|
39
|
+
if (federated === candidate) occurrences += 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (occurrences <= 1) {
|
|
43
|
+
return passResult('id-unique');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const finding: RuleFinding = {
|
|
47
|
+
reason: 'id-unique.collision',
|
|
48
|
+
message: `ADR id "${ctx.proposed.frontmatter.id}" is not unique within log "${ctx.proposed.log ?? ''}"`,
|
|
49
|
+
adr: ctx.proposed.frontmatter.id,
|
|
50
|
+
candidateAdr: ctx.proposed.frontmatter.id,
|
|
51
|
+
recordPath: ctx.proposed.path,
|
|
52
|
+
field: 'id',
|
|
53
|
+
lowerLevel: { rule: 'unique-id', path: ctx.proposed.path, id: ctx.proposed.frontmatter.id, field: 'id' },
|
|
54
|
+
};
|
|
55
|
+
const subs: SubResult[] = [{ status: 'fail', reason: 'id-unique.collision', finding }];
|
|
56
|
+
return aggregate('id-unique', subs);
|
|
57
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — rule kernel (result builders).
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the canonical aggregation (`report/aggregate.ts`) and provides the small
|
|
5
|
+
* result builders rules use for clean pass / inert / not-evaluated outcomes. Keeping a
|
|
6
|
+
* single `aggregate` implementation avoids drift between rules and report assembly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { RULE_REASON_PRECEDENCE, type ReasonCode, type RuleId } from '../catalog.ts';
|
|
10
|
+
import type { RuleFinding, RuleResult } from '../types.ts';
|
|
11
|
+
|
|
12
|
+
export { aggregate, type SubResult } from '../report/aggregate.ts';
|
|
13
|
+
|
|
14
|
+
/** A clean pass with no sub-findings, using the rule's `.ok` reason by default. */
|
|
15
|
+
export function passResult(
|
|
16
|
+
rule: RuleId,
|
|
17
|
+
reason: ReasonCode = RULE_REASON_PRECEDENCE[rule][0] as ReasonCode,
|
|
18
|
+
): RuleResult {
|
|
19
|
+
return { rule, status: 'pass', reason, findings: [] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An inert (degraded) result — backing absent, never a violation. */
|
|
23
|
+
export function inertResult(rule: RuleId, reason: ReasonCode, finding?: RuleFinding): RuleResult {
|
|
24
|
+
return { rule, status: 'inert', reason, findings: finding ? [finding] : [] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A not-evaluated result (schema-invalid short-circuit or prereq-failed only). */
|
|
28
|
+
export function notEvaluated(
|
|
29
|
+
rule: RuleId,
|
|
30
|
+
reason: 'not-evaluated.schema-invalid' | 'not-evaluated.prereq-failed',
|
|
31
|
+
): RuleResult {
|
|
32
|
+
return { rule, status: 'not-evaluated', reason, findings: [] };
|
|
33
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — rule 4: no-orphan-refs (error).
|
|
3
|
+
*
|
|
4
|
+
* Local `supersedes` / `relatesTo` targets must resolve. A federated ref (`<log>:<id>`)
|
|
5
|
+
* resolves against a supplied federated-log snapshot; a federated ref whose log has NO
|
|
6
|
+
* snapshot is inert (`federated-log-absent`), never an orphan failure (C2). `supersededBy`
|
|
7
|
+
* is owned by supersession-consistent and is not re-reported here. Exactly one aggregate
|
|
8
|
+
* result (C11); status precedence keeps a dangling failure above a federated inert.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Adr } from '@adrkit/core';
|
|
12
|
+
import { aggregate, passResult, type SubResult } from './kernel.ts';
|
|
13
|
+
import type { RuleContext } from './context.ts';
|
|
14
|
+
import type { ReasonCode, RuleFinding, RuleResult } from '../types.ts';
|
|
15
|
+
|
|
16
|
+
type RefField = 'supersedes' | 'relatesTo';
|
|
17
|
+
|
|
18
|
+
interface Parsed {
|
|
19
|
+
readonly log?: string;
|
|
20
|
+
readonly id: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseRef(ref: string): Parsed {
|
|
24
|
+
const idx = ref.indexOf(':');
|
|
25
|
+
if (idx <= 0) return { id: ref };
|
|
26
|
+
return { log: ref.slice(0, idx), id: ref.slice(idx + 1) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function evaluateNoOrphanRefs(ctx: RuleContext): RuleResult {
|
|
30
|
+
const resolutionLog = ctx.input.resolutionLog;
|
|
31
|
+
|
|
32
|
+
const localIds = new Set<string>();
|
|
33
|
+
const knownLogs = new Map<string, Set<string>>();
|
|
34
|
+
for (const record of ctx.corpusRecords) {
|
|
35
|
+
if (record.log === undefined || record.log === resolutionLog) {
|
|
36
|
+
localIds.add(record.frontmatter.id);
|
|
37
|
+
}
|
|
38
|
+
if (record.log !== undefined) {
|
|
39
|
+
const set = knownLogs.get(record.log) ?? new Set<string>();
|
|
40
|
+
set.add(record.frontmatter.id);
|
|
41
|
+
knownLogs.set(record.log, set);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const snapshot of ctx.input.federatedLogs ?? []) {
|
|
45
|
+
const set = knownLogs.get(snapshot.log) ?? new Set<string>();
|
|
46
|
+
for (const id of snapshot.adrIds) set.add(id);
|
|
47
|
+
knownLogs.set(snapshot.log, set);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const subs: SubResult[] = [];
|
|
51
|
+
|
|
52
|
+
function classify(ref: string): 'resolved' | 'dangling' | 'federated-absent' {
|
|
53
|
+
const parsed = parseRef(ref);
|
|
54
|
+
if (parsed.log === undefined || parsed.log === resolutionLog) {
|
|
55
|
+
return localIds.has(parsed.id) ? 'resolved' : 'dangling';
|
|
56
|
+
}
|
|
57
|
+
const known = knownLogs.get(parsed.log);
|
|
58
|
+
if (!known) return 'federated-absent';
|
|
59
|
+
return known.has(parsed.id) ? 'resolved' : 'dangling';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function check(record: Adr, field: RefField, refs: readonly string[]): void {
|
|
63
|
+
const danglingReason: ReasonCode =
|
|
64
|
+
field === 'supersedes' ? 'no-orphan-refs.dangling-supersedes' : 'no-orphan-refs.dangling-relates-to';
|
|
65
|
+
const lowerRule = field === 'supersedes' ? 'dangling-supersedes' : 'dangling-relatesTo';
|
|
66
|
+
for (const ref of refs) {
|
|
67
|
+
const outcome = classify(ref);
|
|
68
|
+
if (outcome === 'resolved') continue;
|
|
69
|
+
if (outcome === 'federated-absent') {
|
|
70
|
+
const finding: RuleFinding = {
|
|
71
|
+
reason: 'no-orphan-refs.federated-log-absent',
|
|
72
|
+
message: `Federated ref "${ref}" has no external-log snapshot; reference is inert`,
|
|
73
|
+
candidateAdr: record.frontmatter.id,
|
|
74
|
+
relatedAdr: ref,
|
|
75
|
+
recordPath: record.path,
|
|
76
|
+
field,
|
|
77
|
+
};
|
|
78
|
+
subs.push({ status: 'inert', reason: 'no-orphan-refs.federated-log-absent', finding });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const finding: RuleFinding = {
|
|
82
|
+
reason: danglingReason,
|
|
83
|
+
message: `Reference "${ref}" in ${field} does not resolve to a record in the corpus`,
|
|
84
|
+
adr: record.frontmatter.id,
|
|
85
|
+
candidateAdr: record.frontmatter.id,
|
|
86
|
+
relatedAdr: ref,
|
|
87
|
+
recordPath: record.path,
|
|
88
|
+
field,
|
|
89
|
+
lowerLevel: { rule: lowerRule, path: record.path, id: record.frontmatter.id, field },
|
|
90
|
+
};
|
|
91
|
+
subs.push({ status: 'fail', reason: danglingReason, finding });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const record of ctx.corpusRecords) {
|
|
96
|
+
check(record, 'supersedes', record.frontmatter.supersedes);
|
|
97
|
+
check(record, 'relatesTo', record.frontmatter.relatesTo);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (subs.length === 0) return passResult('no-orphan-refs');
|
|
101
|
+
return aggregate('no-orphan-refs', subs);
|
|
102
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adrkit/evaluator — rule 1: schema-valid (error).
|
|
3
|
+
*
|
|
4
|
+
* Reads only the parse/contract findings on `proposalPath` (data-model §3). Any such
|
|
5
|
+
* finding => fail (error); the orchestrator then emits ten `not-evaluated` results
|
|
6
|
+
* (C11). Lower-level rule/path/id/field/pattern evidence is preserved in report-only
|
|
7
|
+
* `RuleFinding` fields; `RuleFinding.adr` stays strictly an AdrRef and never holds a
|
|
8
|
+
* filesystem path (T017).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Finding } from '@adrkit/core';
|
|
12
|
+
import { aggregate, passResult, type SubResult } from './kernel.ts';
|
|
13
|
+
import type { ProposalResolution, ReasonCode, RuleFinding, RuleResult } from '../types.ts';
|
|
14
|
+
|
|
15
|
+
const PARSE_RULES: ReadonlySet<string> = new Set(['frontmatter-parse', 'frontmatter-fence']);
|
|
16
|
+
|
|
17
|
+
function reasonForFinding(finding: Finding): ReasonCode {
|
|
18
|
+
if (finding.rule === 'file-read') return 'schema-valid.file-read';
|
|
19
|
+
if (PARSE_RULES.has(finding.rule)) return 'schema-valid.parse-error';
|
|
20
|
+
return 'schema-valid.contract-error';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toRuleFinding(finding: Finding): RuleFinding {
|
|
24
|
+
return {
|
|
25
|
+
reason: reasonForFinding(finding),
|
|
26
|
+
...(finding.message ? { message: finding.message } : {}),
|
|
27
|
+
...(finding.path ? { recordPath: finding.path } : {}),
|
|
28
|
+
...(finding.field ? { field: finding.field } : {}),
|
|
29
|
+
lowerLevel: {
|
|
30
|
+
rule: finding.rule,
|
|
31
|
+
...(finding.path ? { path: finding.path } : {}),
|
|
32
|
+
...(finding.id ? { id: finding.id } : {}),
|
|
33
|
+
...(finding.field ? { field: finding.field } : {}),
|
|
34
|
+
...(finding.pattern ? { pattern: finding.pattern } : {}),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function evaluateSchemaValid(resolution: ProposalResolution): RuleResult {
|
|
40
|
+
const findings = resolution.schemaFindings;
|
|
41
|
+
if (findings.length === 0) {
|
|
42
|
+
return passResult('schema-valid');
|
|
43
|
+
}
|
|
44
|
+
const subs: SubResult[] = findings.map((finding) => {
|
|
45
|
+
const ruleFinding = toRuleFinding(finding);
|
|
46
|
+
return { status: 'fail', reason: ruleFinding.reason, finding: ruleFinding };
|
|
47
|
+
});
|
|
48
|
+
return aggregate('schema-valid', subs);
|
|
49
|
+
}
|