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
29 changes: 29 additions & 0 deletions .changeset/lint-script-retired-key-house-sentence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@objectstack/lint": patch
"@objectstack/spec": patch
---

fix(lint): script-node retired-key diagnostic no longer says "rewrite it" (#7030)

`validate-expressions`'s lint diagnostic for a `script` node carrying a retired
dispatch key (`config.actionType` / `template` / `recipients` / `variables` /
`script`, retired in `@objectstack/spec` 17, #4343) closed with `Run \`os
migrate meta --from 16\` to rewrite it automatically.` For the
`template`/`recipients`/`variables`/`script` branches the value is **deleted**,
not rewritten into anything, so "rewrite **it**" named the wrong antecedent —
the same false-antecedent shape #6856 (route D, maintainer-ruled) already swept
out of every `packages/spec/src` tombstone. This was the one live site the
sweep's scan surface (`packages/spec/src` only) could not see.

The sentence now reads `Run \`os migrate meta --from 16\` to rewrite existing
sources automatically.` — naming a property of the TOOL (it rewrites your
source files), never the retired key's fate, which the message body already
states per branch. Message copy only: the diagnostic still fires on the same
inputs, at the same severity, with the same `#4343` / per-key / replacement
guidance untouched.

`packages/spec/src/shared/retired-key-migrate-sentence.test.ts` — the #6856
class pin — is widened to scan `packages/lint/src` alongside `packages/spec/src`
so this sentence cannot drift from the house form again, in either package.
That widening is test-only (no `@objectstack/spec` runtime code changed);
listed here only because the pin's own file lives inside `packages/spec`.
10 changes: 10 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
expect(issues[0].message).toMatch(/config\.template/);
expect(issues[0].message).toMatch(/`notify` node/);
expect(issues[0].message).toMatch(/os migrate meta --from 16/);
// #7030 — house sentence (#6856 route D): names the TOOL's behaviour, never
// the retired key's fate. This branch can DELETE the key outright
// (`template`/`recipients`/`variables`/`script`), so "rewrite it" reads two
// ways ("it" = the key vs. "it" = your sources) while "rewrite existing
// sources" has one antecedent. Pinned here AND class-wide in
// `retired-key-migrate-sentence.test.ts` (widened to `packages/lint/src`).
expect(issues[0].message).toMatch(
/Run `os migrate meta --from 16` to rewrite existing sources automatically\.$/,
);
expect(issues[0].message).not.toMatch(/rewrite it automatically/);
});

it('tells a shorthand actionType exactly where its name belongs', () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,14 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
? `\`actionType: '${action}'\` named a registered function — move it to \`function: '${action}'\`. `
: `Use a \`notify\` node for mail, a \`connector_action\` (Slack connector) or \`http\` node ` +
`for Slack, and a registered function for logic. `) +
`Run \`os migrate meta --from 16\` to rewrite it automatically.`,
// #6856 route D (maintainer-ruled): the house sentence names the TOOL's
// behaviour, never the retired key's fate — "rewrite it" reads two ways
// over a branch that DELETES the key (template/recipients/variables/script),
// "rewrite existing sources" only one. Plain-quoted (not a template literal)
// so this site is a member of `retired-key-migrate-sentence.test.ts`'s
// widened scan (#7030) on the same textual shape as the spec corpus — no
// interpolation lives in this clause, so nothing is lost switching quote style.
'Run `os migrate meta --from 16` to rewrite existing sources automatically.',
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
});
} else if (!fn) {
Expand Down
85 changes: 67 additions & 18 deletions packages/spec/src/shared/retired-key-migrate-sentence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,25 @@
* rest>." (`ui/dashboard.zod.ts` `compareTo.offset` is the model; the script
* node's `config.actionType` is the other member.)
*
* [#7030] Widened, not duplicated: `packages/lint/src/validate-expressions.ts`
* carries one live occurrence of the identical sentence (the lint diagnostic
* for a script node's retired dispatch keys — same #6856 ruling, same
* false-antecedent risk, since that branch too can DELETE the key rather than
* rewrite it into anything). `judgeMigrateSentences` is a plain text scan with
* no dependency on `retiredKey()` or on anything `packages/spec`-specific, so
* this pin's INPUT (the CORPORA it walks) widens for free — the matching
* mechanism below is unchanged. A second, standalone pin over that one lint
* site could only drift from this one the moment either wording changes;
* one pin covering both corpora cannot.
*
* Mechanism: a SOURCE scan over string literals (this pin pins textual facts —
* the sentences ARE text in source). Comment lines are skipped: descriptive
* prose about the tool ("`os migrate meta` rewrites sources") is not a
* prescription. `migrations/registry.ts` is out of scope structurally — it is
* the migration LEDGER, whose `notes` are release prose over whole migrations,
* not tombstone prescriptions an author meets in a parse error. That is a
* scope bound on the corpus, not a per-site exemption: every prescription
* string in every schema file is judged, with no allowlist.
* scope bound on the spec corpus, not a per-site exemption: every
* prescription string in every scanned file is judged, with no allowlist.
*
* What this pin deliberately does NOT check: a tombstone whose prescription
* carries no `os migrate meta` sentence at all (#6914's worklist) — absence of
Expand All @@ -43,10 +54,33 @@ import url from 'node:url';
import { describe, expect, it } from 'vitest';

const HERE = path.dirname(url.fileURLToPath(import.meta.url));
const SRC_ROOT = path.resolve(HERE, '..');
const SPEC_SRC_ROOT = path.resolve(HERE, '..');
/** #7030: `packages/lint/src`, the one other corpus carrying this sentence. */
const LINT_SRC_ROOT = path.resolve(HERE, '../../../lint/src');

/** One scanned corpus: a root directory, plus its own out-of-scope exemptions. */
interface Corpus {
/** Short label, used as the `file` prefix on judged sites (e.g. `spec:`, `lint:`). */
name: string;
root: string;
/** Paths relative to `root` that are structurally out of scope (see module doc). */
outOfScope: Set<string>;
}

/** The migration ledger — release prose, not tombstone prescriptions (see module doc). */
const OUT_OF_SCOPE = new Set([path.join('migrations', 'registry.ts')]);
const CORPORA: Corpus[] = [
{
name: 'spec',
root: SPEC_SRC_ROOT,
// The migration ledger — release prose, not tombstone prescriptions (see module doc).
outOfScope: new Set([path.join('migrations', 'registry.ts')]),
},
{
// #7030: `validate-expressions.ts`'s script-node lint diagnostic is the only site.
name: 'lint',
root: LINT_SRC_ROOT,
outOfScope: new Set(),
},
];

const MARKER = /(?:Run )?`os migrate meta --from \d+`/g;

Expand All @@ -66,7 +100,7 @@ const MIXED_AT_MARKER =
/^Run `os migrate meta --from \d+` to rewrite the [^;'"]+ case[^;'"]* automatically; [^;'"]+\.['"]/;

interface JudgedSite {
/** Path relative to `packages/spec/src`. */
/** Corpus-prefixed path, e.g. `spec:data/object.zod.ts` or `lint:validate-expressions.ts`. */
file: string;
/** 1-based line of the sentence's marker (best effort across concatenation). */
line: number;
Expand Down Expand Up @@ -129,16 +163,18 @@ function* walk(dir: string): Generator<string> {

function judgeTree(): JudgedSite[] {
const all: JudgedSite[] = [];
for (const file of walk(SRC_ROOT)) {
const rel = path.relative(SRC_ROOT, file);
if (OUT_OF_SCOPE.has(rel)) continue;
all.push(...judgeMigrateSentences(fs.readFileSync(file, 'utf8'), rel));
for (const corpus of CORPORA) {
for (const file of walk(corpus.root)) {
const rel = path.relative(corpus.root, file);
if (corpus.outOfScope.has(rel)) continue;
all.push(...judgeMigrateSentences(fs.readFileSync(file, 'utf8'), `${corpus.name}:${rel}`));
}
}
return all;
}

describe('retiredKey() `os migrate meta` sentences are the house sentence (#6856 route D)', () => {
it('every prescription sentence in packages/spec/src is house-form or MIXED two-clause', () => {
describe('`os migrate meta` sentences are the house sentence, across corpora (#6856 route D, widened #7030)', () => {
it('every prescription sentence in packages/spec/src and packages/lint/src is house-form or MIXED two-clause', () => {
const judged = judgeTree();
const violations = judged.filter((j) => !j.ok);
expect(
Expand All @@ -149,17 +185,30 @@ describe('retiredKey() `os migrate meta` sentences are the house sentence (#6856
).toEqual([]);
});

it('anti-vacuity: the scanner actually judges the corpus (floor, not a census)', () => {
// 54 prescription sentences at the time of the sweep. The floor guards
// against the SCANNER going blind (a regex or comment-filter regression
// reporting an empty corpus as green), not against tombstones aging out —
// lower it deliberately, with the removal that shrinks the corpus, when
// that day comes. #6914's 35 pending sentences will only raise the count.
it('anti-vacuity: the scanner actually judges every corpus (floor, not a census)', () => {
// 54 prescription sentences in packages/spec/src at the time of the #6856
// sweep; packages/lint/src contributes one more under #7030's widened
// scan. The floor guards against a SCANNER going blind on either corpus (a
// regex or comment-filter regression reporting an empty tree as green),
// not against tombstones aging out — lower it deliberately, with the
// removal that shrinks a corpus, when that day comes. #6914's 35 pending
// sentences will only raise the count further.
const judged = judgeTree();
expect(judged.length).toBeGreaterThanOrEqual(50);
expect(judged.every((j) => j.ok)).toBe(true);
});

it('anti-vacuity: the lint corpus specifically is reached, not just outnumbered by spec', () => {
// The combined floor above (>=50) is already satisfied by packages/spec/src
// alone, so a broken LINT_SRC_ROOT (wrong relative path, corpus silently
// walking zero files) would NOT fail it — this assertion is the one thing
// that actually exercises #7030's widening rather than merely declaring it.
const judged = judgeTree();
const lintSites = judged.filter((j) => j.file.startsWith('lint:'));
expect(lintSites.length).toBeGreaterThanOrEqual(1);
expect(lintSites.every((j) => j.ok)).toBe(true);
});

it('goes RED on the retired "rewrite it" spelling, naming the site', () => {
const planted = [
"const X = retiredKey(",
Expand Down
Loading