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
4 changes: 2 additions & 2 deletions packages/objectql/src/action-governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ export function isObjectLessActionKey(objectName: string | undefined | null): bo
* Standalone `action` metadata declares `objectName` (spec `ActionSchema`);
* bundle collectors attach `object`; an object-less action owns the canonical
* `'global'` key. Three other writers spelled this same three-line ladder —
* the runtime's `standaloneActionObjectName`, the ObjectQL plugin's private
* `actionObjectKey`, and an inline copy inside
* the runtime's `standaloneActionObjectName`, a private owner-key method on the
* ObjectQL plugin, and an inline copy inside
* {@link collectEngineActionDeclarations}. All of them resolve HERE now: the
* plugin calls this function directly (same package) and
* `@objectstack/runtime` re-exports it, keeping `standaloneActionObjectName`
Expand Down
299 changes: 291 additions & 8 deletions packages/objectql/src/action-owner-key-single-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
* silently different the first time that constant moves.
*
* `@objectstack/runtime` carries the matching weld for its own copy
* (`action-owner-key-single-source.test.ts` there). This one is scoped to this
* package's source so it stays a package-local test input.
* (`action-owner-key-single-source.test.ts` there). The LADDER halves below are
* scoped to this package's source so they stay package-local test inputs; the
* absence half is not, and the section on it explains why.
*/

import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
import { GLOBAL_ACTION_OBJECT_KEY, standaloneActionOwnerKey } from './action-governance.js';

Expand Down Expand Up @@ -66,12 +68,14 @@ describe('standalone-action owner key — one spelling in @objectstack/objectql
}
});

it('leaves no private `actionObjectKey` behind on the plugin', () => {
it('derives the plugin owner key through the canonical helper', () => {
const plugin = nonTestSources().find((s) => s.file === 'plugin.ts');
expect(plugin, 'plugin.ts is missing from the scan').toBeDefined();
expect(plugin!.text).not.toContain('actionObjectKey');
// Positive control for the negative above: the plugin does still derive
// owner keys — it just does it through the canonical helper now.
// The negative that used to live here — "plugin.ts does not name the
// deleted member" — moved to the TREE-scoped section at the bottom of
// this file (#14878). Its scope was the defect, not its subject. What
// stays here is the positive half: the plugin still derives owner keys,
// it just does it through the canonical helper now.
expect(plugin!.text).toContain('standaloneActionOwnerKey(');
});

Expand All @@ -89,3 +93,282 @@ describe('standalone-action owner key — one spelling in @objectstack/objectql
expect(body[1]).not.toContain("'global'");
});
});

/**
* ── [#14878] The absence assertion is TREE-scoped, not FILE-scoped ──────────
*
* The negative that used to sit in the plugin test above read `plugin.ts` and
* nothing else, and THAT SCOPE was the defect. A pin written by the deleting PR
* can only look where its author thought to look, and the whole failure mode is
* references the author did not know about: the file-scoped pin stayed green
* while five other files in three other packages went on naming the deleted
* member as something that reads a key TODAY, and the one deletion produced two
* separate follow-up cards.
*
* Widening to the tree also covers the half that nothing keyed on the deleting
* diff can ever see. Three of those five references already existed when the
* member died. The other two were written 1 h 41 min AFTER it, by a later PR,
* into a file that was clean at deletion time — so a check that greps the
* deleting PR's own post-image is structurally blind to them. A pin that runs on
* every PR is not: it reddens on the second kind at the moment it is written,
* which is the only moment the person who can classify the mention is present.
*
* ⛔ AN ASSERTION OF ABSENCE IS NOT A STALE MENTION. This file and its twin in
* `@objectstack/runtime` name the dead member because naming it is how they hunt
* for it. "Repairing" those lines deletes the guard — a naive fixer turning a
* pin into its own removal. That is why the two pins exclude themselves below,
* with the reason written beside the rule; it is the first thing to get right
* about this shape, not a refinement of it.
*
* ── Scope, and where it stops ───────────────────────────────────────────────
*
* `.ts` under `packages/`, and that boundary is a MEASURED TRADE rather than a
* default — read this before widening it.
*
* `examples/` was in the scan for one commit. It is the right radius on the
* evidence (one of the five surviving references lived there), and the repo's
* gate farm refused it: declaring an examples-wide `.ts` glob in
* `scripts/cross-package-test-inputs.mjs` makes that glob an inherited watch
* hint on every importer of that table, `check:cross-package-test-inputs`
* included — and `dispatch-gates.mjs`'s self-test pins that no hint of that gate
* reaches a test file outside `packages/**`, because the whole reason it is
* listed as a change-KIND rather than a path derivation is that the hint route
* cannot reach the population it judges. Measured on this tree: all 41 tracked
* test files outside `packages/` are under `examples/`, so that one glob does
* not shrink the residue class, it EMPTIES it, and the case cannot be
* re-pointed at another member because there is none.
*
* ⇒ Widening this pin to `examples/` is not a two-line change and ⛔ must not be
* done by editing that self-test case. It needs the residue measurement behind
* that case redone, which is a `scripts/pm/` decision owned by another lane.
* What it costs today, stated rather than discovered later: of this symbol's
* five surviving references, four were `packages/**` and one was a test under
* the showcase example — which this pin would not have caught.
*
* ⚠️ Any widening — `examples/`, `docs/`, `content/`, `skills/`, `apps/` — is
* TWO edits, never one: `SCANNED_ROOTS` here AND this package's globs in
* `scripts/cross-package-test-inputs.mjs` (a NEW top-level root needs a matching
* ci.yml `crosspkg:` entry too, which `check-ci-filter-parity.mjs` gates).
* Widening the scanner alone reads as coverage while turbo never re-runs this
* suite for the files it now claims to judge.
*/

/**
* The member PR #14667 deleted from `ObjectQLPlugin`. Held as DATA: naming a
* symbol in a string cannot resurrect it, and this file is excluded from its own
* scan precisely so it may carry the name.
*/
const DELETED_PLUGIN_MEMBER = 'actionObjectKey';

/**
* The live spelling that replaced it. Used as the scan's reach control below —
* it is the one symbol guaranteed to sit in both scanned roots for as long as
* the convergence holds, and if it ever stops doing so this pin should say so
* loudly rather than quietly stop reaching.
*/
const CANONICAL_HELPER = 'standaloneActionOwnerKey';

/** The two pins that hunt the dead member, and therefore have to name it. */
const PIN_FILES: readonly string[] = [
'packages/objectql/src/action-owner-key-single-source.test.ts',
'packages/runtime/src/action-owner-key-single-source.test.ts',
];

/**
* Where a mention of the dead member is NOT a defect, each with its reason
* beside it.
*
* ⛔ This is a scan-SCOPE decision written where the scan lives, and it must
* stay that: an allowlist FILE — one more path pasted in whenever a report is
* inconvenient — is the permission slip this whole shape exists to avoid. A rule
* here has to be a statement about a CLASS of file that is true by construction,
* never "this one site is fine".
*
* The first two rules cannot fire while `SCANNED_EXTENSION` is `.ts`, and they
* are kept anyway: they are the ruled exclusions, and the day someone widens the
* extension set they are what stops the release record from being re-admitted as
* a pile of false reds.
*/
const NOT_A_STALE_MENTION: ReadonlyArray<{ readonly covers: (file: string) => boolean; readonly why: string }> = [
{
// A published CHANGELOG entry is the record OF the removal. It is true in
// the past tense, it is what a consumer reads to find out the member is
// gone, and rewriting it would falsify shipped release history.
covers: (file) => file === 'CHANGELOG.md' || file.endsWith('/CHANGELOG.md'),
why: 'a published CHANGELOG is the record of the removal itself',
},
{
// The same record before the release process compiles it into the above.
covers: (file) => file.startsWith('.changeset/'),
why: 'a changeset is that record before it is compiled into a CHANGELOG',
},
{
// The pins carry the name as their own search string and as accurate
// history of what they pin. Excluding them is what lets the pin exist:
// a scan that flagged its own needle would have no green state at all.
covers: (file) => PIN_FILES.includes(file),
why: 'the pin carries the name as its own search string — repairing it deletes the guard',
},
];

/**
* This package is CJS-typed (no `"type": "module"`), so `module: NodeNext`
* forbids `import.meta` here — the same constraint `srcDir()` above records.
* Walk up from the CWD to this package's own manifest instead, which works
* wherever vitest is invoked from.
*/
function findUp(marker: (dir: string) => boolean, what: string): string {
let dir = process.cwd();
for (;;) {
if (marker(dir)) return dir;
const parent = dirname(dir);
if (parent === dir) throw new Error(`could not locate ${what} walking up from ${process.cwd()}`);
dir = parent;
}
}

const PACKAGE_ROOT = findUp((dir) => {
const manifest = join(dir, 'package.json');
if (!existsSync(manifest)) return false;
const { name } = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string };
return name === '@objectstack/objectql';
}, 'the @objectstack/objectql package root');

/**
* The repo root by ARITHMETIC from this package rather than by a second
* marker-file walk, deliberately: a walk keyed on a workspace-root marker would
* NAME that root file, and a declared root-level path is a new top-level root
* that ci.yml's `crosspkg:` filter would then have to carry. Anchoring off the
* manifest keeps this pin's declared radius inside roots that already exist.
*
* The arithmetic is not trusted on faith — the reach test below fails on any
* wrong root, because no wrong root can see both scanned trees.
*/
const REPO_ROOT = resolve(PACKAGE_ROOT, '../..');

/** The tree this pin binds. See the scope note above before changing it. */
const SCANNED_ROOTS: readonly string[] = ['packages'];

/** Spelled once so the declared glob and the scan stay in correspondence. */
const SCANNED_EXTENSION = '.ts';

/**
* Generous on purpose. The scan is one `git grep` and a handful of file reads —
* tens of milliseconds — so this is not a budget, it is headroom against a
* merge-queue runner doing a full monorepo build at the same time. A pin that
* times out before its assertion runs reports nothing, and reporting nothing is
* indistinguishable from finding nothing.
*/
const SCAN_TIMEOUT_MS = 60_000;

function git(args: string[]): string[] {
let stdout: string;
try {
stdout = execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 1 << 28 });
} catch (error) {
const failure = error as { status?: number; stderr?: string };
// `git grep` exits 1 for "found nothing", which is data. Anything else is
// a BROKEN scan and must never read as "no stale mentions" — throwing
// here, plus the reach test below, is what keeps a green result meaning
// "looked and found nothing" rather than "never looked".
if (failure.status === 1) return [];
throw new Error(
`git ${args.join(' ')} failed with status ${String(failure.status)}: ${failure.stderr ?? ''}`,
);
}
return stdout.split('\0').filter((entry) => entry.length > 0);
}

/**
* Every scanned file that so much as mentions `symbol`.
*
* Tracked files PLUS untracked ones with ignored paths excluded (`--untracked`)
* — i.e. exactly the files a human authored, never build output. A file written
* but not yet `git add`ed still reddens, which is what makes this a local-loop
* guard rather than something you find out about in the merge queue.
*/
function filesMentioning(symbol: string): string[] {
return git([
'grep',
'--files-with-matches',
'-z',
'--untracked',
'--text',
'--fixed-strings',
'-e',
symbol,
'--',
...SCANNED_ROOTS,
]).filter((file) => file.endsWith(SCANNED_EXTENSION));
}

/** `<file>:<line>` for every mention that no rule above excuses. */
function staleMentionSites(symbol: string): string[] {
const sites: string[] = [];
for (const file of filesMentioning(symbol)) {
if (NOT_A_STALE_MENTION.some((rule) => rule.covers(file))) continue;
const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n');
lines.forEach((text, index) => {
if (text.includes(symbol)) sites.push(`${file}:${index + 1}`);
});
}
return sites;
}

describe('standalone-action owner key — the deleted member is dead TREE-WIDE (#14878)', () => {
it(
'is named nowhere outside the release record and the two pins',
() => {
const sites = staleMentionSites(DELETED_PLUGIN_MEMBER);
expect(
sites,
sites.length === 0
? ''
: [
`These files name \`${DELETED_PLUGIN_MEMBER}\`, a private \`ObjectQLPlugin\``,
'member that was DELETED when the standalone-action owner-key ladder was',
'converged onto one spelling:',
'',
...sites.map((site) => ` - ${site}`),
'',
`The live spelling is \`${CANONICAL_HELPER}\`, exported from`,
'`@objectstack/objectql` (packages/objectql/src/action-governance.ts). If the',
'sentence is otherwise accurate, rename the one word rather than rewriting',
'the clause — the neighbouring names in these sentences are usually alive.',
'',
'⛔ Before you touch a site, decide which of three it is:',
' (a) a LIVE CLAIM that the member exists -> fix it',
' (b) accurate HISTORY naming it in the past -> reword so it no longer',
' carries the dead name, or add a rule to NOT_A_STALE_MENTION above',
' with the reason beside it — never an allowlist file',
' (c) an ASSERTION THAT IT IS GONE -> ⛔ leave it alone. It is the guard.',
].join('\n'),
).toEqual([]);
},
SCAN_TIMEOUT_MS,
);

it(
'the scan reaches both roots and can see the name it hunts',
() => {
// Anti-vacuity, at both stages a tree scan can go silently blind.
//
// A grep that matched nothing — wrong repo root, git missing, a
// pathspec that names no tree — yields the same empty violation set
// as a clean repo, and the assertion above cannot tell them apart.
// That is the exact property the file-scoped pin lost.
expect(filesMentioning(DELETED_PLUGIN_MEMBER)).toContain(PIN_FILES[0]);

// ...and it must LEAVE this package, which is the half a file-scoped
// pin never had. The live helper is the reach control: it is the one
// symbol the convergence guarantees outside this package, and the CLI
// site below is one of the files that carried the DEAD name until it
// was repaired — so a scan that cannot see it is a scan that would
// not have caught the defect this pin exists for.
const reached = filesMentioning(CANONICAL_HELPER);
expect(reached).toContain('packages/cli/src/commands/lint.ts');
expect(reached).toContain('packages/runtime/src/action-execution.ts');
},
SCAN_TIMEOUT_MS,
);
});
Loading
Loading