|
|
/**
|
|
|
* Census guard — the sound catalogue is the ONLY place a sound is authored.
|
|
|
*
|
|
|
* WHY THIS EXISTS, and what it used to guard. Until 2026-08-06 the catalogue
|
|
|
* held parametric DELTAS (`SOUND_TUNINGS`) whose keys had to be headed by a
|
|
|
* sema family, because a delta only means something over a base. This file
|
|
|
* enforced that grammar. The law was born from a real drift: `form.*` entered
|
|
|
* on 2026-05-19 naming seven form controls truthfully, spread to menus and
|
|
|
* trees a week later, and by 2026-08-05 had fifty consumers of which exactly
|
|
|
* ONE was the Form. Nothing caught it for two and a half months because
|
|
|
* nothing looked at the keys.
|
|
|
*
|
|
|
* The grammar is gone because the reason for it is gone. A catalogue entry is
|
|
|
* no longer a delta over a family — it is a NAME a component may say, and the
|
|
|
* same `soft` serves a commit and an emerge alike, because the family still
|
|
|
* supplies its own identity underneath. Heading names with a family would now
|
|
|
* be the lie, not the truth.
|
|
|
*
|
|
|
* WHAT SURVIVES, because it is about values and not about names:
|
|
|
*
|
|
|
* 1. Nothing in the catalogue resolves to silence. Silence is `SILENT`, one
|
|
|
* canonical value the resolver honours by dropping the channel — never a
|
|
|
* number, because later layers move numbers. A "silenced" toggle once
|
|
|
* measured −13.9 dBFS with `risk`, louder than a real button press, for
|
|
|
* exactly this reason.
|
|
|
* 2. Every entry is playable: a complete, finite signature once resolved over
|
|
|
* a family base (checked in `sound-names.test.ts`, which also snapshots
|
|
|
* what each name sounds like).
|
|
|
* 3. No component authors a sound. That is now a TYPE — `Sema['cascade']`
|
|
|
* accepts a name or `SILENT` — but a type only guards TypeScript, so the
|
|
|
* census below also reads the pack SOURCES: a raw parameter bag written in
|
|
|
* a `.ts` the compiler happens not to narrow is still the defect.
|
|
|
*/
|
|
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
import { join } from 'node:path';
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
import { execSync } from 'node:child_process';
|
|
|
|
|
|
import { SILENT } from './channels';
|
|
|
import { SOUNDS, namedSound, type SoundName } from './sound-names';
|
|
|
|
|
|
const NAMES = Object.keys(SOUNDS) as SoundName[];
|
|
|
|
|
|
/**
|
|
|
* Pack sources, listed via git — never by walking the tree (worktrees, build/)
|
|
|
* — and with comments stripped, because prose that MENTIONS the old shape is
|
|
|
* not the old shape. Scanning raw text flagged five doc blocks the first time
|
|
|
* this ran.
|
|
|
*/
|
|
|
function packSources(): { file: string; source: string }[] {
|
|
|
const root = join(__dirname, '..', '..', '..');
|
|
|
const out = execSync('git ls-files src/uix/sema/components', { cwd: root, encoding: 'utf-8' });
|
|
|
return out
|
|
|
.split('\n')
|
|
|
.filter((f) => f.endsWith('.ts') && !f.endsWith('index.ts'))
|
|
|
.map((f) => ({
|
|
|
file: f.replace(/^.*\//, ''),
|
|
|
source: readFileSync(join(root, f), 'utf-8')
|
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
|
.replace(/(^|\s)\/\/[^\n]*/g, '$1')
|
|
|
}));
|
|
|
}
|
|
|
|
|
|
describe('the sound catalogue is the only place a sound is authored', () => {
|
|
|
it('finds the catalogue (sanity: the census is not scanning an empty const)', () => {
|
|
|
expect(NAMES.length).toBeGreaterThan(12);
|
|
|
});
|
|
|
|
|
|
it('lets no name resolve to silence — silence is a value, not a level', () => {
|
|
|
const offenders: string[] = [];
|
|
|
for (const name of NAMES) {
|
|
|
const gain = (namedSound(name) as { gain?: unknown }).gain;
|
|
|
if (typeof gain === 'number' && gain <= 0) offenders.push(`${name}: gain ${gain}`);
|
|
|
}
|
|
|
expect(
|
|
|
offenders,
|
|
|
`silence is ${String(SILENT)}, one canonical value the resolver honours by dropping the ` +
|
|
|
'channel. A gain of zero is a NUMBER, and later layers move numbers.'
|
|
|
).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('names every entry in lowercase dotted segments', () => {
|
|
|
const malformed = NAMES.filter((k) => k.split('.').some((s) => !/^[a-z][a-z-]*$/.test(s)));
|
|
|
expect(malformed, 'a name is lowercase segments — it is typed, so it must read well').toEqual(
|
|
|
[]
|
|
|
);
|
|
|
});
|
|
|
|
|
|
it('lets no pack author a sound instead of naming one', () => {
|
|
|
const offenders: string[] = [];
|
|
|
for (const { file, source } of packSources()) {
|
|
|
// A rule may say `sound: 'name'`, `sound: SILENT`, or nothing. Anything
|
|
|
// else — an object literal, a call — is authoring.
|
|
|
for (const match of source.matchAll(/\bsound:\s*(.)/g)) {
|
|
|
const head = match[1];
|
|
|
if (head === "'" || head === 'S') continue;
|
|
|
const line = source.slice(0, match.index).split('\n').length;
|
|
|
offenders.push(`${file}:${line}`);
|
|
|
}
|
|
|
}
|
|
|
expect(
|
|
|
offenders,
|
|
|
'a pack NAMES a sound (`sound-names.ts`) or says SILENT. Parameters live in one place — ' +
|
|
|
'the measured cost of the alternative was 214 rules authoring 33 signatures, 92 of ' +
|
|
|
'them saying nothing but a volume'
|
|
|
).toEqual([]);
|
|
|
});
|
|
|
|
|
|
it('leaves no pack importing the retired arithmetic catalogue', () => {
|
|
|
const stale = packSources()
|
|
|
.filter(({ source }) => /soundTuning|SOUND_TUNINGS|\bsound\(/.test(source))
|
|
|
.map(({ file }) => file);
|
|
|
expect(stale, '`SOUND_TUNINGS` / `soundTuning()` / `sound()` were retired 2026-08-06').toEqual(
|
|
|
[]
|
|
|
);
|
|
|
});
|
|
|
});
|