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
13 changes: 10 additions & 3 deletions scripts/layering/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
// - Over WORKSPACE PACKAGE EXPORTS: no package but test-kit publishes test support (fixtures,
// fakes, mocks, harnesses, `__tests__/`, `./testing/*`); the offender set is a merge-base
// ratchet, and production code may not import those exports or test-kit (R83).
// - Over UNIT TESTS: each `*.test.ts` under `src/` and `packages/*/src/` is named for a module
// beside it; the unmatched set is a merge-base ratchet (R84).
// - Over CONTRACTS PRODUCTION SOURCE: contracts owns vocabulary only — host, process, and timer
// mechanics belong in capture-kit or an adapter (R18).
// R6, R9, and the R10 R7 counts are ratchets with no written-down reference: each is the same
Expand Down Expand Up @@ -143,13 +145,14 @@ import {
packageExportSurfaceSummary,
} from './package-export-surface.ts';
import { checkTestKitIsolation } from './test-kit-isolation.ts';
import { checkTestColocation, testColocationSummary } from './test-colocation.ts';
import { checkDaemonLayers, DAEMON_ROOT } from './daemon-layers.ts';
import { checkSessionAuthorityOverlay, handlerOwnedOverlay } from './session-authority-overlay.ts';
import {
DAEMON_SOURCE_ROOT,
listTrackedPlatformZoneFiles,
listTrackedProductionSources,
listTrackedPackageFiles,
listTrackedSourceTreeFiles,
listTrackedSrcUtilsFiles,
listTrackedTypeScriptFiles,
} from './tracked-sources.ts';
Expand Down Expand Up @@ -446,7 +449,8 @@ function report(
`no production daemon file reaches the root platform-runtime family or a hub that ` +
`does (R76); the daemon process reaches no caller-side replay source module over ` +
`static or dynamic edges (R82); ` +
`${packageExportSurfaceSummary(ratchets.testOnlyExports, reference.testOnlyExports)}; and ` +
`${packageExportSurfaceSummary(ratchets.testOnlyExports, reference.testOnlyExports)}; ` +
`${testColocationSummary(ratchets.unmatchedTests, reference.unmatchedTests)}; and ` +
`the handler-owned SessionState/SessionStore ` +
`authority overlay holds at or under the merge-base (R75, ` +
`${handlerOwnedShapeFiles} shape / ${handlerOwnedAuthorityFiles} authority files); and ` +
Expand Down Expand Up @@ -535,6 +539,7 @@ export const LAYERING_RULE_IDS = [
'session-authority-overlay',
'daemon-layers',
'package-export-surface',
'test-colocation',
] as const;

export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number];
Expand Down Expand Up @@ -615,6 +620,8 @@ export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
),
...checkTestKitIsolation(readTrackedPackageManifests(repoRoot), context.allTypeScriptSources),
],
'test-colocation': (context) =>
checkTestColocation(context.ratchets.unmatchedTests, context.reference.unmatchedTests),
};

export function main(): number {
Expand All @@ -629,7 +636,7 @@ export function main(): number {
// Measured once and threaded: the rules and the success line must report the same numbers.
const ratchets = measureRatchets(sources, edges, {
manifests: readTrackedPackageManifests(repoRoot),
packageFiles: listTrackedPackageFiles(repoRoot),
files: listTrackedSourceTreeFiles(repoRoot),
});
const reference = mergeBaseRatchets(repoRoot, parse);
const context: LayeringContext = {
Expand Down
16 changes: 14 additions & 2 deletions scripts/layering/ratchet-reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { test } from 'node:test';
import { memoizedImportParser, resolveImportEdges } from './model.ts';
import { measureRatchets, mergeBaseRatchets } from './ratchet-reference.ts';
import { inHeadCoordinates, measureRatchets, mergeBaseRatchets } from './ratchet-reference.ts';
import { sessionStateWritePressure } from './session-state.ts';
import { listTrackedProductionSources } from './tracked-sources.ts';
import { readCommittedSources } from '../__tests__/committed-source-tree.ts';
Expand Down Expand Up @@ -52,6 +52,17 @@ test('sessionStateWritePressure counts written fields and (field, writer) claims
});
});

test('carried merge-base entries map to head paths and stay sorted', () => {
const moved = new Map([['src/z-old.test.ts', 'src/a-new.test.ts']]);
assert.deepEqual(
inHeadCoordinates(
['src/b.test.ts', 'src/z-old.test.ts', 'src/c.test.ts'],
(file) => moved.get(file) ?? file,
),
['src/a-new.test.ts', 'src/b.test.ts', 'src/c.test.ts'],
);
});

test('measureRatchets reports all ratchets from one tree', () => {
const sources = tree();
assert.deepEqual(measureRatchets(sources, resolveImportEdges(sources)), {
Expand All @@ -60,6 +71,7 @@ test('measureRatchets reports all ratchets from one tree', () => {
sessionState: { writerOwnedFields: 2, ownerFileClaims: 3 },
sessionAuthority: { shapeFiles: [], authorityFiles: [] },
testOnlyExports: [],
unmatchedTests: [],
});
const withExports = measureRatchets(sources, resolveImportEdges(sources), {
manifests: new Map([
Expand All @@ -68,7 +80,7 @@ test('measureRatchets reports all ratchets from one tree', () => {
JSON.stringify({ name: '@agent-device/p', exports: { './x-fixtures': './src/x.ts' } }),
],
]),
packageFiles: [],
files: [],
});
assert.deepEqual(
withExports.testOnlyExports.map(({ id }) => id),
Expand Down
34 changes: 25 additions & 9 deletions scripts/layering/ratchet-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type SessionAuthorityOverlay,
} from './session-authority-overlay.ts';
import { sessionStateWritePressure, type SessionStateWritePressure } from './session-state.ts';
import { measureUnmatchedTests } from './test-colocation.ts';

export type LayeringRatchets = Readonly<{
/** R6: distinct type-only spine inversions per `from -> to` zone pair. */
Expand All @@ -40,27 +41,41 @@ export type LayeringRatchets = Readonly<{
sessionAuthority: SessionAuthorityOverlay;
/** R83: the test-only package exports published, whole tree, test-kit excluded. */
testOnlyExports: readonly TestOnlyExport[];
/** R84: unit tests named for no module beside them, sorted. */
unmatchedTests: readonly string[];
}>;

/** What R83 measures a tree from: its package manifests and its tracked package paths. */
export type PackageExportSurfaceInput = Readonly<{
/** What R83 and R84 measure a tree from: its package manifests and its tracked `src/` and `packages/` paths. */
export type RepoTreeInput = Readonly<{
manifests: ReadonlyMap<string, string>;
packageFiles: readonly string[];
files: readonly string[];
}>;

/**
* Merge-base entries in head coordinates, sorted again: a rename can change lexical order, and
* `measureUnmatchedTests` promises sorted output.
*/
export function inHeadCoordinates(
entries: readonly string[],
toHead: (file: string) => string,
): string[] {
return entries.map(toHead).sort();
}

export type MergeBaseRatchets = LayeringRatchets & Readonly<{ ref: string }>;

export function measureRatchets(
sources: ReadonlyMap<string, string>,
edges: readonly ResolvedImportEdge[],
exportSurface: PackageExportSurfaceInput = { manifests: new Map(), packageFiles: [] },
tree: RepoTreeInput = { manifests: new Map(), files: [] },
): LayeringRatchets {
return {
typeInversions: typeInversionCounts(edges),
largestTypeCycle: largestTypeCycleMembers(edges),
sessionState: sessionStateWritePressure(sources),
sessionAuthority: measureSessionAuthorityOverlay(sources, edges),
testOnlyExports: measureTestOnlyExports(exportSurface.manifests, exportSurface.packageFiles),
testOnlyExports: measureTestOnlyExports(tree.manifests, tree.files),
unmatchedTests: measureUnmatchedTests(tree.files),
};
}

Expand Down Expand Up @@ -101,11 +116,12 @@ export function mergeBaseRatchets(
: { ...edge, file, target, fromZone: targetDagZone(file), toZone: targetDagZone(target) };
});
const canonical = new Map([...sources].map(([file, text]) => [toHead(file), text]));
const base = measureRatchets(canonical, edges, { manifests, files });
return {
ref,
...measureRatchets(canonical, edges, {
manifests,
packageFiles: files.filter((file) => file.startsWith('packages/')),
}),
...base,
// Base entries in head coordinates, like the edges above: a test moved or renamed since the
// merge-base is the same entry, not new.
unmatchedTests: inHeadCoordinates(base.unmatchedTests, toHead),
};
}
165 changes: 165 additions & 0 deletions scripts/layering/test-colocation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// R84 test-colocation: which tests count as sitting beside a module they are named for, and the
// merge-base ratchet over those that do not.

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import {
checkTestColocation,
measureUnmatchedTests,
TEST_COLOCATION_RULE,
testColocationSummary,
} from './test-colocation.ts';

function unmatched(...files: string[]) {
return measureUnmatchedTests(files);
}

test('a test named for the module beside it, or for it plus an aspect, is matched', () => {
assert.deepEqual(
unmatched(
'packages/p/src/store.ts',
'packages/p/src/store.test.ts',
'packages/p/src/store-recovery.test.ts',
'src/commands/open.ts',
'src/commands/open-flags.test.ts',
),
[],
);
});

test('a test named for no module in its directory is unmatched', () => {
assert.deepEqual(
unmatched(
'packages/p/src/store.ts',
'packages/p/src/other.test.ts',
'packages/p/src/store2.test.ts',
'packages/p/src/sto.test.ts',
),
['packages/p/src/other.test.ts', 'packages/p/src/sto.test.ts', 'packages/p/src/store2.test.ts'],
);
});

test('a declaration module is named for its stem: foo.d.ts matches foo.test.ts', () => {
assert.deepEqual(unmatched('src/foo.d.ts', 'src/foo.test.ts', 'src/foo-shape.test.ts'), []);
assert.deepEqual(unmatched('src/foo.d.ts', 'src/foo.d.test.ts'), ['src/foo.d.test.ts']);
});

test('the aspect suffix is dash-separated: a dotted or glued name does not match', () => {
assert.deepEqual(
unmatched(
'packages/p/src/store.ts',
'packages/p/src/store.recovery.test.ts',
'packages/p/src/storerecovery.test.ts',
),
['packages/p/src/store.recovery.test.ts', 'packages/p/src/storerecovery.test.ts'],
);
});

test('a test in __tests__ is judged against the modules of its parent directory', () => {
assert.deepEqual(
unmatched(
'packages/p/src/handlers/lease.ts',
'packages/p/src/handlers/__tests__/lease.test.ts',
'packages/p/src/handlers/__tests__/lease-expiry.test.ts',
'src/session.ts',
'src/__tests__/session.test.ts',
),
[],
);
assert.deepEqual(
unmatched(
'packages/p/src/handlers/lease.ts',
'packages/p/src/handlers/__tests__/session.test.ts',
'packages/p/src/handlers/__tests__/support/lease.test.ts',
),
[
'packages/p/src/handlers/__tests__/session.test.ts',
'packages/p/src/handlers/__tests__/support/lease.test.ts',
],
);
});

test('modules inside __tests__ do not make a test matched', () => {
assert.deepEqual(
unmatched('packages/p/src/__tests__/helper.ts', 'packages/p/src/__tests__/helper.test.ts'),
['packages/p/src/__tests__/helper.test.ts'],
);
});

test('index is an ordinary module: a cross-module package test is index-<aspect>.test.ts', () => {
assert.deepEqual(
unmatched(
'packages/p/src/index.ts',
'packages/p/src/index-surface.test.ts',
'packages/p/src/index-conformance.test.ts',
),
[],
);
assert.deepEqual(unmatched('packages/p/src/index.ts', 'packages/p/src/surface.test.ts'), [
'packages/p/src/surface.test.ts',
]);
// Without an index.ts beside it there is nothing for the name to point at.
assert.deepEqual(unmatched('packages/p/src/index-surface.test.ts'), [
'packages/p/src/index-surface.test.ts',
]);
});

test('fixtures never count as the module a test is named for, and test-kit is not measured', () => {
assert.deepEqual(unmatched('packages/p/src/store.fixtures.ts', 'packages/p/src/store.test.ts'), [
'packages/p/src/store.test.ts',
]);
assert.deepEqual(unmatched('packages/test-kit/src/other.test.ts'), []);
});

test('test/, scripts/ and package test trees are exempt: the rule reads src trees only', () => {
assert.deepEqual(
unmatched(
'test/integration/smoke.test.ts',
'test/repo-guards/swift-sources.test.ts',
'scripts/layering/check-wiring.test.ts',
'packages/p/test/conformance.test.ts',
'packages/p/package.json',
),
[],
);
});

test('a daemon-composition style suite under src/__tests__ is unmatched', () => {
assert.deepEqual(
unmatched('src/__tests__/daemon-composition/handlers/claims.test.ts', 'src/session.ts'),
['src/__tests__/daemon-composition/handlers/claims.test.ts'],
);
});

test('growth against the merge-base fails; moving a test into place or an unchanged set passes', () => {
const base = ['src/__tests__/a.test.ts', 'src/__tests__/b.test.ts'];
const violations = checkTestColocation(
['src/__tests__/b.test.ts', 'src/__tests__/c.test.ts'],
base,
);
assert.equal(violations.length, 1);
assert.equal(violations[0]!.rule, TEST_COLOCATION_RULE);
assert.equal(violations[0]!.file, 'src/__tests__/c.test.ts');
assert.match(violations[0]!.message, /index-<aspect>\.test\.ts/);
assert.match(violations[0]!.message, /test\/repo-guards\//);
assert.deepEqual(checkTestColocation([], base), []);
assert.deepEqual(checkTestColocation(base, base), []);
});

test('the remedy locations R84 names are collected by a vitest project', () => {
// A test moved to test/repo-guards/ must run somewhere: no gate fails on a test no project collects.
const config = fs.readFileSync(
path.resolve(import.meta.dirname, '../../vitest.config.ts'),
'utf8',
);
assert.match(config, /'test\/repo-guards\/\*\.test\.ts'/);
});

test('the summary reports the remaining count against the merge-base', () => {
assert.match(
testColocationSummary(['a'], ['a', 'b']),
/1 unmatched test\(s\) remain against 2 at the merge-base \(R84\)/,
);
});
Loading
Loading