Skip to content

Commit 1b27a9f

Browse files
committed
fix: phase 1 of diagnostic setup
1 parent c42cb80 commit 1b27a9f

14 files changed

Lines changed: 1062 additions & 307 deletions

File tree

scripts/_test/_lib.mjs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Shared workspace-walking helpers for the `_test:*` private NX subtargets.
3+
*
4+
* Each `_test:*` subtarget under `tests:test`'s barrel has a focused script in this
5+
* directory that enforces one structural workspace invariant. They share the helpers
6+
* defined here — `findCsproj`, `findCs`, `findJson`, path resolution — so the cost of
7+
* adding a new invariant is one new script, not one new toolchain.
8+
*
9+
* See [docs/scratch/data-extension-contract.md §6 "Workspace Target Architecture"](../../docs/scratch/data-extension-contract.md#6-workspace-target-architecture)
10+
* for the full pattern; see [tests/README.md](../../tests/README.md) for kit infrastructure.
11+
*/
12+
13+
import { readdirSync, existsSync } from 'node:fs';
14+
import { join, sep } from 'node:path';
15+
import { fileURLToPath } from 'node:url';
16+
import { resolve, dirname } from 'node:path';
17+
18+
const __dirname = dirname(fileURLToPath(import.meta.url));
19+
20+
/** Workspace root (two levels up from this file: scripts/_test/_lib.mjs → repo root). */
21+
export const ROOT = resolve(__dirname, '..', '..');
22+
23+
/** Conventional source / tests / kit directories under ROOT. */
24+
export const SRC_DIR = join(ROOT, 'src');
25+
export const TESTS_DIR = join(ROOT, 'tests');
26+
export const KITS_DIR = join(TESTS_DIR, 'helpers', 'Flowthru.Tests.Kits');
27+
28+
/** Recursively collect all .csproj paths under `dir`. */
29+
export function findCsproj(dir) {
30+
const results = [];
31+
if (!existsSync(dir)) return results;
32+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
33+
if (entry.name.startsWith('.')) continue;
34+
const fullPath = join(dir, entry.name);
35+
if (entry.isDirectory()) {
36+
results.push(...findCsproj(fullPath));
37+
} else if (entry.name.endsWith('.csproj')) {
38+
results.push(fullPath);
39+
}
40+
}
41+
return results;
42+
}
43+
44+
/**
45+
* Recursively collect all .cs files under `dir`, skipping `obj/`, `bin/`, and
46+
* `TestResults/` directories.
47+
*/
48+
export function findCs(dir) {
49+
const results = [];
50+
if (!existsSync(dir)) return results;
51+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
52+
if (entry.name.startsWith('.')) continue;
53+
if (entry.name === 'obj' || entry.name === 'bin' || entry.name === 'TestResults') {
54+
continue;
55+
}
56+
const fullPath = join(dir, entry.name);
57+
if (entry.isDirectory()) {
58+
results.push(...findCs(fullPath));
59+
} else if (entry.name.endsWith('.cs')) {
60+
results.push(fullPath);
61+
}
62+
}
63+
return results;
64+
}
65+
66+
/** Recursively collect all .json files under `dir`, skipping standard build dirs. */
67+
export function findJson(dir) {
68+
const results = [];
69+
if (!existsSync(dir)) return results;
70+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
71+
if (entry.name.startsWith('.')) continue;
72+
if (entry.name === 'obj' || entry.name === 'bin' || entry.name === 'TestResults') {
73+
continue;
74+
}
75+
const fullPath = join(dir, entry.name);
76+
if (entry.isDirectory()) {
77+
results.push(...findJson(fullPath));
78+
} else if (entry.name.endsWith('.json')) {
79+
results.push(fullPath);
80+
}
81+
}
82+
return results;
83+
}
84+
85+
/** Recursively collect all .md files under `dir`, skipping standard build dirs. */
86+
export function findMd(dir) {
87+
const results = [];
88+
if (!existsSync(dir)) return results;
89+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
90+
if (entry.name.startsWith('.') && entry.name !== '.claude') continue;
91+
if (entry.name === 'obj' || entry.name === 'bin' || entry.name === 'node_modules') continue;
92+
const fullPath = join(dir, entry.name);
93+
if (entry.isDirectory()) {
94+
results.push(...findMd(fullPath));
95+
} else if (entry.name.endsWith('.md')) {
96+
results.push(fullPath);
97+
}
98+
}
99+
return results;
100+
}
101+
102+
/** Relative path from ROOT, using forward slashes for legible output. */
103+
export function rel(absPath) {
104+
return absPath.slice(ROOT.length + 1).replaceAll(sep, '/');
105+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#!/usr/bin/env node
2+
/**
3+
* `_test:conformance-presence` — every first-party Flowthru extension that implements a
4+
* Core extension surface (`IStorageAdapter<T>`, `IFormatSerializer<TRow>`, `IStorageMedium`,
5+
* `IStorageMediumProvider`, `IMetadataProvider`) has at least one corresponding `*Conformance`
6+
* subclass in its sibling `tests/extensions/<Ext>.Tests/` project.
7+
*
8+
* The check is at the *extension level*, not per implementor: an extension passes if any
9+
* conformance subclass for the relevant surface kind exists, regardless of which specific
10+
* implementor it covers. This admits patterns like Gql, where `GqlQueryStorageAdapter`
11+
* doesn't fit the kit's contract (read-only deferred handle) but its sibling
12+
* `GqlStorageAdapter` does and has a conformance subclass. The extension-level
13+
* aggregation also lets multi-shape conformance (e.g., EFCore's flat + nested entities)
14+
* cover multiple implementors with one parameterized base.
15+
*
16+
* Container-adapter conformance (`IContainerAdapter<TContainer, TRow>`) is intentionally
17+
* excluded: the kit does not yet codify a `ContainerAdapterConformance` base, and the only
18+
* first-party container adapter outside Core (MLNet's `DataViewContainerAdapter`) was
19+
* descoped from the conformance initiative because ONNX is structurally different from
20+
* the row-oriented surfaces the kit targets. Restore it to the SURFACES list when
21+
* container conformance is added.
22+
*
23+
* Exits with non-zero on any failure. Extracted from the previous monolithic
24+
* `verify-test-coverage.mjs` Pass 2.
25+
*
26+
* Usage:
27+
* node scripts/_test/conformance-presence.mjs
28+
*/
29+
30+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
31+
import { join } from 'node:path';
32+
import { findCs, rel, SRC_DIR, TESTS_DIR } from './_lib.mjs';
33+
34+
// ── Surfaces in scope for conformance enforcement ────────────────────────────
35+
//
36+
// Each surface maps from the Core interface name (matched on the impl side) to the
37+
// kit base class name (matched on the test side).
38+
//
39+
const SURFACES = [
40+
{ iface: 'IStorageAdapter', base: 'StorageAdapterConformance' },
41+
{ iface: 'IFormatSerializer', base: 'FormatSerializerConformance' },
42+
{ iface: 'IStorageMedium', base: 'StorageMediumConformance' },
43+
{ iface: 'IStorageMediumProvider', base: 'StorageMediumProviderConformance' },
44+
{ iface: 'IMetadataProvider', base: 'MetadataProviderConformance' },
45+
];
46+
47+
/**
48+
* Returns the set of surface keys (e.g., 'IStorageAdapter') the file declares as base
49+
* types or implemented interfaces. Matches `: IStorageAdapter<` and similar across multi-
50+
* line declarations by collapsing whitespace before regex matching.
51+
*/
52+
function detectSurfaceImpls(filePath) {
53+
const text = readFileSync(filePath, 'utf8');
54+
const collapsed = text.replace(/\s+/g, ' ');
55+
const found = new Set();
56+
for (const { iface } of SURFACES) {
57+
// Match `: IStorageAdapter<` (with or without surrounding whitespace) anywhere a base
58+
// list could appear. Also match `, IStorageAdapter<` (additional interfaces).
59+
const re = new RegExp(`[:,]\\s*${iface}(?:<|\\s)`, 'g');
60+
if (re.test(collapsed)) {
61+
found.add(iface);
62+
}
63+
}
64+
return found;
65+
}
66+
67+
/**
68+
* Returns the set of conformance-base keys (e.g., 'StorageAdapterConformance') the file
69+
* declares as a base class.
70+
*/
71+
function detectConformanceBases(filePath) {
72+
const text = readFileSync(filePath, 'utf8');
73+
const collapsed = text.replace(/\s+/g, ' ');
74+
const found = new Set();
75+
for (const { base } of SURFACES) {
76+
// Match `: StorageAdapterConformance<` or `: StorageAdapterConformance ` (no generics).
77+
const re = new RegExp(`[:,]\\s*${base}(?:<|\\s|$)`, 'g');
78+
if (re.test(collapsed)) {
79+
found.add(base);
80+
}
81+
}
82+
return found;
83+
}
84+
85+
const EXTENSIONS_SRC = join(SRC_DIR, 'extensions');
86+
const EXTENSIONS_TESTS = join(TESTS_DIR, 'extensions');
87+
88+
const conformanceFailures = [];
89+
90+
if (existsSync(EXTENSIONS_SRC)) {
91+
for (const entry of readdirSync(EXTENSIONS_SRC, { withFileTypes: true })) {
92+
if (!entry.isDirectory()) continue;
93+
if (!entry.name.startsWith('Flowthru.Extensions.')) continue;
94+
95+
const extName = entry.name;
96+
const extSrcDir = join(EXTENSIONS_SRC, extName);
97+
const extTestsDir = join(EXTENSIONS_TESTS, `${extName}.Tests`);
98+
99+
// Collect all surface kinds this extension implements.
100+
const implementedSurfaces = new Set();
101+
for (const csFile of findCs(extSrcDir)) {
102+
const impls = detectSurfaceImpls(csFile);
103+
for (const surface of impls) {
104+
implementedSurfaces.add(surface);
105+
}
106+
}
107+
108+
if (implementedSurfaces.size === 0) {
109+
// Extension implements no kit-tracked surface (e.g., EFCore.Bulk supplies saveFunc
110+
// delegates only). Nothing to enforce.
111+
continue;
112+
}
113+
114+
// Collect all conformance bases the test project covers.
115+
const coveredBases = new Set();
116+
if (existsSync(extTestsDir)) {
117+
for (const csFile of findCs(extTestsDir)) {
118+
const bases = detectConformanceBases(csFile);
119+
for (const base of bases) {
120+
coveredBases.add(base);
121+
}
122+
}
123+
}
124+
125+
// Map each implemented surface to its expected conformance base, then check coverage.
126+
const missingBases = [];
127+
for (const surface of implementedSurfaces) {
128+
const entry = SURFACES.find((s) => s.iface === surface);
129+
if (!entry) continue;
130+
if (!coveredBases.has(entry.base)) {
131+
missingBases.push({ surface, base: entry.base });
132+
}
133+
}
134+
135+
if (missingBases.length > 0) {
136+
conformanceFailures.push({
137+
extension: extName,
138+
testsDir: existsSync(extTestsDir) ? rel(extTestsDir) : '<missing>',
139+
missing: missingBases,
140+
});
141+
}
142+
}
143+
}
144+
145+
let exitCode = 0;
146+
147+
if (conformanceFailures.length > 0) {
148+
exitCode = 1;
149+
console.error(
150+
`\n${conformanceFailures.length} extension(s) implement Core surfaces but lack conformance coverage:\n`
151+
);
152+
for (const { extension, testsDir, missing } of conformanceFailures) {
153+
console.error(` extension: ${extension}`);
154+
console.error(` tests: ${testsDir}`);
155+
for (const { surface, base } of missing) {
156+
console.error(` - implements ${surface} but no ${base} subclass found`);
157+
}
158+
console.error('');
159+
}
160+
console.error(
161+
'See `tests/README.md` (Extension Conformance Kits) and ' +
162+
'`docs/scratch/extension-conformance-kits.md` for the kit pattern.\n'
163+
);
164+
}
165+
166+
if (exitCode === 0) {
167+
console.log(
168+
'_test:conformance-presence — every first-party extension surface implementor has a conformance subclass.'
169+
);
170+
}
171+
172+
process.exit(exitCode);

scripts/_test/dead-fixtures.mjs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env node
2+
/**
3+
* `_test:dead-fixtures` — every fixture file under
4+
* `tests/helpers/Flowthru.Tests.Kits/Fixtures/` must be referenced by at least one
5+
* conformance subclass (typically as an entry in a `static IEnumerable<string> Fixtures`
6+
* member, surfaced via `[TestFixtureSource(nameof(Fixtures))]`).
7+
*
8+
* A "dead fixture" is one that exists in the kit's data catalog but isn't loaded by any
9+
* conformance suite. Like dead schemas, it's a false-confidence trap.
10+
*
11+
* Detection strategy:
12+
* 1. Enumerate `.json` fixture files under `Fixtures/`.
13+
* 2. For each fixture, compute its "kit-relative" path (e.g., `Flat/Simple/rows.json`).
14+
* 3. Search `tests/extensions/**\/*.cs` and `tests/core/**\/*.cs` for that string. If
15+
* it appears literally in any source file, the fixture is alive.
16+
*
17+
* Caveats:
18+
* - The literal-string match is intentional: `[TestFixtureSource]` resolves at test-
19+
* run time via NUnit reflection, so a static analyzer can't follow `nameof(Fixtures)`
20+
* back to the array. The kit's authoring convention is to inline fixture paths as
21+
* string literals, so the match is straightforward.
22+
* - A fixture referenced via dynamic concatenation (e.g., `$"{Prefix}/rows.json"`)
23+
* would evade detection. None of the existing kit subclasses use that pattern, but
24+
* callers of this kit who do should be aware.
25+
*
26+
* Usage:
27+
* node scripts/_test/dead-fixtures.mjs
28+
*/
29+
30+
import { readFileSync } from 'node:fs';
31+
import { join, sep } from 'node:path';
32+
import { findJson, findCs, rel, KITS_DIR, TESTS_DIR } from './_lib.mjs';
33+
34+
const FIXTURES_DIR = join(KITS_DIR, 'Fixtures');
35+
const REFERENCE_SEARCH_DIRS = [
36+
join(TESTS_DIR, 'extensions'),
37+
join(TESTS_DIR, 'core'),
38+
];
39+
40+
// ── Enumerate fixtures and compute their kit-relative paths ──────────────────
41+
42+
const fixtures = []; // { absPath, kitRelPath }
43+
for (const fixturePath of findJson(FIXTURES_DIR)) {
44+
const kitRelPath = fixturePath.slice(FIXTURES_DIR.length + 1).replaceAll(sep, '/');
45+
fixtures.push({ absPath: fixturePath, kitRelPath });
46+
}
47+
48+
// ── Build reference index: every .cs source under conformance dirs ───────────
49+
50+
const sourceTexts = [];
51+
for (const dir of REFERENCE_SEARCH_DIRS) {
52+
for (const csFile of findCs(dir)) {
53+
sourceTexts.push(readFileSync(csFile, 'utf8'));
54+
}
55+
}
56+
57+
// ── Match each fixture path against the reference index ─────────────────────
58+
59+
const orphans = [];
60+
for (const { absPath, kitRelPath } of fixtures) {
61+
const found = sourceTexts.some((text) => text.includes(kitRelPath));
62+
if (!found) {
63+
orphans.push({ path: kitRelPath, file: rel(absPath) });
64+
}
65+
}
66+
67+
let exitCode = 0;
68+
if (orphans.length > 0) {
69+
exitCode = 1;
70+
console.error(
71+
`\n${orphans.length} kit fixture(s) exist but are not referenced by any conformance subclass:\n`
72+
);
73+
for (const { path, file } of orphans) {
74+
console.error(` ${path} (${file})`);
75+
}
76+
console.error(
77+
'\nA fixture is "alive" iff at least one conformance subclass references its kit-relative path'
78+
);
79+
console.error(
80+
'(typically in a `static IEnumerable<string> Fixtures` array on a `[TestFixtureSource]`-decorated subclass).'
81+
);
82+
console.error(
83+
'Either wire up a conformance subclass that exercises this fixture, or remove the fixture.\n'
84+
);
85+
}
86+
87+
if (exitCode === 0) {
88+
console.log(
89+
`_test:dead-fixtures — all ${fixtures.length} kit fixtures are referenced by at least one conformance subclass.`
90+
);
91+
}
92+
93+
process.exit(exitCode);

0 commit comments

Comments
 (0)