Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,35 @@
import * as fs from 'fs';
import * as path from 'path';
import {
ADR_CONFORMANCE_CATEGORY,
RULE_TRIAGE,
RuleEvaluability,
isNonExecutable,
} from './rule-evaluability';
import { RULESETS_ROOT, triageCorpus } from '../../../test/rule-corpus-triage';

// The corpus loader, the real handler set and the classification live in
// `test/rule-corpus-triage.ts`. They used to live HERE, which meant jest was the
// only thing that could run them — and that is why
// `src/rulesets/standards/native-evaluability-snapshot.json`, which needs exactly
// this computation, was maintained by hand and drifted. The last describe block
// below is the other half of the repair.
const { corpus: CORPUS, classified: CLASSIFIED, summary: SUMMARY, claims } = triageCorpus();
import { REPO_ROOT, RULESETS_ROOT, triageCorpus, renderSnapshot } from '../../../test/rule-corpus-triage';

// The corpus loader, the real handler set, the classification AND the snapshot
// renderer live in `test/rule-corpus-triage.ts`. They used to live HERE, which
// meant jest was the only thing that could run them — and that is why
// `native-evaluability-snapshot.json` was maintained by hand and drifted.
//
// GT-633 RECONCILIATION: the renderer in particular had two implementations, one
// here and one in `capture-native-evaluability-snapshot.mjs`, written in parallel
// by sessions that could not see each other. Two generators for one artifact is
// the defect GT-633 exists to remove, one level up: whichever ran last would win
// and the other's `--check` would go red for no visible reason. There is now ONE
// renderer, and this suite is its PIN rather than a second copy of it.
const TRIAGE = triageCorpus();
const { corpus: CORPUS, classified: CLASSIFIED, summary: SUMMARY, claims } = TRIAGE;

/**
* The class counts this repository is pinned to.
*
* Also the counts `native-evaluability-snapshot.json` must reproduce, and the
* ones its capture script writes. Read out of this file by
* `src/rulesets/standards/iso-5055-mapping.test.mjs`, which runs in a job with no
* node_modules and so cannot recompute them — keep it a plain literal.
* Also the counts `native-evaluability-snapshot.json` must reproduce. READ OUT OF
* THIS FILE by `src/rulesets/standards/iso-5055-mapping.test.mjs`, which runs in a
* job with no node_modules and so cannot recompute them — keep it a plain literal,
* and note that that guard THROWS if it cannot find this declaration rather than
* passing. Restoring it is what a reconciliation that deleted it owes back.
*/
const PINNED_CLASS_COUNTS: Readonly<Record<RuleEvaluability, number>> = {
'native-handler': 154,
Expand All @@ -54,6 +62,41 @@ const PINNED_CLASS_COUNTS: Readonly<Record<RuleEvaluability, number>> = {
underspecified: 14,
};

const SNAPSHOT_FILE = path.join(REPO_ROOT, 'src', 'rulesets', 'standards', 'native-evaluability-snapshot.json');

/**
* `capturedOn` is carried forward from the committed document, exactly as the
* capture script does, so the pin compares classification against classification
* and never fails on a date.
*/
function committedCapturedOn(): string {
return (JSON.parse(fs.readFileSync(SNAPSHOT_FILE, 'utf8')) as { capturedOn: string }).capturedOn;
}

describe('GT-595 · the capture consumed by src/rulesets/standards', () => {
it('renders one class per rule, losing no id to a key collision', () => {
// `classes` is keyed by rule id alone, so two rules sharing an id in
// different files would silently collapse into one entry and understate
// every count downstream of it.
const rendered = JSON.parse(renderSnapshot(TRIAGE, { capturedOn: committedCapturedOn() })) as {
counts: Record<string, number>;
classes: Record<string, string>;
};
expect(Object.keys(rendered.classes)).toHaveLength(CORPUS.length);
expect(Object.values(rendered.counts).reduce((a, b) => a + b, 0)).toBe(CORPUS.length);
});

it('keeps native-evaluability-snapshot.json byte-identical to a fresh capture', () => {
// Deliberately does NOT write. There is one writer —
// `node src/rulesets/standards/capture-native-evaluability-snapshot.mjs` —
// and it is the one the GT-630 chain guard and `docs.yml` invoke. A second
// writer is how this artifact ended up with two generators in the first place.
const fresh = renderSnapshot(TRIAGE, { capturedOn: committedCapturedOn() });

expect(fs.readFileSync(SNAPSHOT_FILE, 'utf8')).toBe(fresh);
});
});

describe('GT-595 · the corpus is fully classified', () => {
it('loads a corpus of the expected size (guards the measurement itself)', () => {
// If this moves, every number below moved with it — re-triage before editing.
Expand Down Expand Up @@ -107,11 +150,11 @@ describe('GT-595 · the published breakdown, with its denominator', () => {
// `enforce` was dropped at normalization. It is now carried, and
// `ModuleBoundaryRuleHandler` evaluates it.
// `unimplemented-native` drops by the same twelve; nothing else moved.
//
// 151 -> 154 on 2026-07-29: GT-580 added `cli/exit-code-taxonomy.rules.json`
// (CLI-EXIT-01/02/03) with `CliExitTaxonomyRuleHandler` claiming all three,
// so the corpus grows by three and every one of them lands directly in
// `native-handler`. No other class moves: these ids did not exist before.
// Asserted against the SINGLE declaration above, not against a second inline
// copy of the same six numbers. There used to be two — this constant, read by
// the dependency-free guard in `src/rulesets/standards`, and a literal here —
// which is GT-633's own defect in miniature: two copies of one fact, and
// editing either leaves the other silently disagreeing.
expect(SUMMARY.byClass).toEqual(PINNED_CLASS_COUNTS);
});

Expand Down Expand Up @@ -370,60 +413,3 @@ describe('GT-595 · OCB-02 is vacuous as written — measured, not asserted', ()
expect(parsed.openCoreMatrix?.enterprise?.length).toBeGreaterThan(0);
});
});

/**
* GT-598 — the snapshot `src/rulesets/standards` consumes must be a CAPTURE.
*
* `native-evaluability-snapshot.json` is read by `build-iso-5055-mapping.mjs`,
* which stamps `nativeEvaluability` onto every row of the ISO/IEC 5055 mapping.
* A stale snapshot therefore does not stay contained: it launders a wrong
* classification into a larger derived artifact and misstates the handler
* backlog. It said `documentation-only: 129` long after this file pinned 136,
* and its own guard could not see that — it compared the snapshot against six
* numbers typed into the test, the same six the snapshot already contained.
*
* This is where the truth is computed, so this is where the comparison belongs.
* The guard in `src/rulesets/standards` runs in a job with no node_modules and
* cannot recompute anything; it reads {@link PINNED_CLASS_COUNTS} out of this
* file instead. If this block fails, run:
* node src/rulesets/standards/capture-native-evaluability-snapshot.mjs
* node src/rulesets/standards/build-iso-5055-mapping.mjs
* in that order — the second consumes what the first writes.
*/
describe('GT-598 · the captured snapshot still matches a fresh triage', () => {
const SNAPSHOT_PATH = path.join(RULESETS_ROOT, 'standards', 'native-evaluability-snapshot.json');
const snapshot = JSON.parse(fs.readFileSync(SNAPSHOT_PATH, 'utf8')) as {
counts: Record<string, number>;
classes: Record<string, RuleEvaluability>;
};

it('agrees on the class counts', () => {
expect(snapshot.counts).toEqual(PINNED_CLASS_COUNTS);
});

it('agrees on the class of every rule, id by id', () => {
// Duplicate ids across rulesets collapse to one entry in the snapshot, so
// compare against the same collapse rather than against the corpus length.
const fresh: Record<string, RuleEvaluability> = {};
for (const c of CLASSIFIED) fresh[c.ruleId] = c.evaluability;

const drifted = Object.keys(fresh)
.filter(id => snapshot.classes[id] !== fresh[id])
.map(id => `${id}: snapshot says ${snapshot.classes[id] ?? '(absent)'}, triage says ${fresh[id]}`);
const orphaned = Object.keys(snapshot.classes).filter(id => !(id in fresh));

expect(drifted).toEqual([]);
expect(orphaned).toEqual([]);
});

it('carries counts that agree with its own per-rule classes', () => {
// A snapshot whose header disagreed with its body would let the .mjs guard
// pass on the header while the mapping was stamped from the body.
const tally: Record<string, number> = {};
for (const rule of CORPUS) {
const klass = snapshot.classes[rule.id];
tally[klass] = (tally[klass] ?? 0) + 1;
}
expect(tally).toEqual(snapshot.counts);
});
});
83 changes: 83 additions & 0 deletions src/packages/core-domain/test/rule-corpus-triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
ADR_CONFORMANCE_CATEGORY,
ClassifiedRule,
EvaluabilitySummary,
RuleEvaluability,
classifyRule,
summarizeEvaluability,
} from '../src/application/validators/rule-evaluability';
Expand Down Expand Up @@ -161,3 +162,85 @@ export function triageCorpus(rulesetsRoot: string = RULESETS_ROOT): CorpusTriage

return { corpus, classified, summary: summarizeEvaluability(classified), claims, evaluates };
}

// ---------------------------------------------------------------------------
// The rendered capture — ONE renderer, two callers
// ---------------------------------------------------------------------------

/**
* GT-633 reconciliation: this function had TWO implementations.
*
* The fix for GT-633 was written twice, in parallel, by sessions that could not
* see each other. One shipped a standalone capture script (`capture-native-
* evaluability-snapshot.mjs`, reachable by the derived-artifact chain guard and
* by the documentation job); the other rendered the document inside
* `rule-corpus-triage.spec.ts`, where jest can pin it byte-for-byte. Both were
* right about their half, and both recaptured the same numbers.
*
* But two generators for one artifact is EXACTLY the defect GT-633 exists to
* remove, reproduced one level up: whichever ran last would win, and the other's
* `--check` would go red for no reason a reader could see.
*
* So the document is rendered HERE, once, and both callers use it:
* - `rule-corpus-triage.spec.ts` pins it byte-for-byte (it can recompute);
* - `capture-native-evaluability-snapshot.mjs` writes it and `--check`s it,
* which is what the GT-630 chain and `docs.yml` invoke.
*
* `capturedOn` is deliberately STICKY: it is passed in by the caller from the
* committed document when the classification has not moved, so replaying the
* chain stays byte-identical instead of rewriting a date on every run.
*/
export const SNAPSHOT_SHAPE_VERSION = '1.1.0';

/** One reading order for the six classes, shared by `counts` and `validation`. */
export const CLASS_ORDER: readonly RuleEvaluability[] = [
'native-handler',
'documentation-only',
'unimplemented-native',
'needs-external-system',
'needs-runtime',
'underspecified',
];

export interface SnapshotRenderOptions {
/** Carried forward from the committed file while the classification is unchanged. */
readonly capturedOn: string;
}

/** The snapshot document, as bytes, straight from a live triage. */
export function renderSnapshot(triage: CorpusTriage, { capturedOn }: SnapshotRenderOptions): string {
const counts: Record<string, number> = {};
for (const klass of CLASS_ORDER) counts[klass] = triage.summary.byClass[klass];

const classes: Record<string, RuleEvaluability> = {};
for (const c of triage.classified) classes[c.ruleId] = c.evaluability;

const doc = {
$id: 'https://evolith.dev/rulesets/standards/native-evaluability-snapshot.json',
title: 'Native-engine evaluability class per rule (snapshot)',
description:
'Per-rule evaluability class as computed by the Core native evaluator triage. This is a GENERATED CAPTURE, not the source of truth: the authority is src/packages/core-domain/src/application/validators/rule-evaluability.ts and the handler set registered in native-evaluator.ts. It is recorded here so the ISO/IEC 5055 mapping can be scoped to the real handler backlog without src/rulesets depending on a package it does not own. Do not hand-edit — regenerate.',
version: SNAPSHOT_SHAPE_VERSION,
capturedOn,
capturedFrom: [
'src/packages/core-domain/src/application/validators/rule-evaluability.ts (RULE_TRIAGE, classifyRule, ADR_CONFORMANCE_CATEGORY)',
'src/packages/core-domain/src/application/validators/evaluators/native-evaluator.ts (registered handler set)',
'src/packages/core-domain/src/application/validators/evaluators/handlers/**/*.ts (canHandle predicates)',
'src/packages/core-domain/test/rule-corpus-triage.ts (corpus loader, classification and this renderer)',
],
regenerateWith: 'node src/rulesets/standards/capture-native-evaluability-snapshot.mjs',
validation: `Rendered by test/rule-corpus-triage.ts from the live triage, written by capture-native-evaluability-snapshot.mjs and pinned byte-for-byte by rule-corpus-triage.spec.ts, so a divergence between this file and Core is a failing test rather than silent drift (corpus ${triage.summary.total}; ${CLASS_ORDER.map(k => `${k} ${triage.summary.byClass[k]}`).join(', ')}).`,
// Both numbers, because they are not the same question — and the
// documentation-job guard asserts on them. `corpusSize` is what Core
// classified and what `counts` sums to; `distinctRuleIds` is how many keys
// `classes` can hold. They differ exactly when one rule id appears in more
// than one ruleset file, and a guard that assumed they were equal would go
// red on a corpus change that is not drift.
corpusSize: triage.corpus.length,
distinctRuleIds: Object.keys(classes).length,
counts,
classes,
};

return JSON.stringify(doc, null, 2) + '\n';
}
Loading
Loading