Skip to content

Commit 994c327

Browse files
committed
fix(adr-0087): a whole package's predicate 2 refuses any code mention, not only an import
Contract review patch round. Predicate 2 for `<package>#*` returns to D9's reading, "no metadata surface references it in code" with comment spans masked: `packageReference` refuses on any non-comment occurrence of the package name outside its own files, an import specifier being the case reported as `import`. New WP-R14 (a string value in an object definition) and WP-U5; WP floor 50 -> 55. The ADR's two closed-vocabulary blocks (2026-08-13 and 2026-10-12 addenda) gain the `<package>#*` marker line and the "three operand kinds" wording, and the D9 note's predicate 2, "single line" and "moved, its directory renamed" wording follow the review. Claude-Session: https://claude.ai/code/session_01JQwxbX6swE1AuYRHTZsPqo Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75285da commit 994c327

2 files changed

Lines changed: 60 additions & 31 deletions

File tree

‎docs/adr/0087-metadata-protocol-upgrade-contract.md‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,7 @@ gate's log and its `--list` output.
753753
<!-- adr-0087: not-required (type-surface-only <path>#<a>.<b>.<member>[, ...]) <why> -->
754754
<!-- adr-0087: not-required (runtime-export-retired <package>#<Export>[, ...]) <why> -->
755755
<!-- adr-0087: not-required (runtime-export-retired <package>#<Type>.<member>[, ...]) <why> -->
756+
<!-- adr-0087: not-required (runtime-export-retired <package>#*) <why> -->
756757
```
757758

758759
**The vocabulary is closed, and every exemption is re-verified on every run** — an
@@ -763,8 +764,9 @@ avoid. `registered` must name ids that resolve *and* are new in the diff;
763764
`no-migration-prescription` is refused by a body that carries a migration
764765
prescription; `runtime-interface-only` is the subject of the rest of this
765766
addendum; `type-surface-only` — the sixth, added 2026-08-30 — is the subject of
766-
the addendum after it; and `runtime-export-retired` — the seventh, added 2026-10-12 —
767-
is the subject of the addendum after that. The checks live in
767+
the addendum after it; and `runtime-export-retired` — the seventh, added 2026-10-12,
768+
whose whole-package operand `<package>#*` was added 2026-10-27 (#26220) — is the
769+
subject of the addendum after that. The checks live in
768770
[`scripts/check-adr-0087-registration.mjs`](../../scripts/check-adr-0087-registration.mjs),
769771
which is also where each one's measured history is written down.
770772

@@ -1035,12 +1037,14 @@ fails predicates 2 and 3 outright — it bumps `@objectstack/spec` and adds
10351037

10361038
## Addendum (2026-10-12) — the seventh category: a published runtime EXPORT retirement (#22918)
10371039

1038-
The vocabulary above gained a seventh answer, with two operand kinds that may be
1039-
mixed in one marker:
1040+
The vocabulary above gained a seventh answer, with three operand kinds that may be
1041+
mixed in one marker (the third, `<package>#*`, amended 2026-10-27, #26220 — see the
1042+
second 2026-10-27 amendment note under D9):
10401043

10411044
```text
10421045
<!-- adr-0087: not-required (runtime-export-retired <package>#<Export>[, ...]) <why> -->
10431046
<!-- adr-0087: not-required (runtime-export-retired <package>#<Type>.<member>[, ...]) <why> -->
1047+
<!-- adr-0087: not-required (runtime-export-retired <package>#*) <why> -->
10441048
```
10451049

10461050
### The dead end it closes
@@ -1161,16 +1165,19 @@ package's root entry is followed through the same mapping.
11611165
> workspace with a published entry: a workspace member, not `private`, whose entry maps
11621166
> as above (an unmappable entry is refused, as for any operand). At HEAD it is absent
11631167
> from the workspace: no `package.json` (its base manifest is gone, and no workspace
1164-
> manifest carries its name, so a package that moved or was renamed is not deleted),
1165-
> not in `pnpm-workspace.yaml` (no glob names its directory literally), and not in the
1166-
> changesets fixed group (no `fixed` group in `.changeset/config.json` lists it; a
1167-
> missing or unreadable config is refused, never read as an empty group).
1168+
> manifest carries its name, so a package that moved, its directory renamed, is not
1169+
> deleted), not in `pnpm-workspace.yaml` (no glob names its directory literally), and
1170+
> not in the changesets fixed group (no `fixed` group in `.changeset/config.json` lists
1171+
> it; a missing or unreadable config is refused, never read as an empty group).
11681172
> 2. **no-metadata-surface** asks the package what it asks an export, at the merge base:
11691173
> none of the package's files is a metadata surface, and no metadata surface outside it
1170-
> imports it in code, by its root or a subpath.
1171-
> 3. **from-operands-named**: the FROM → TO block names the package on **one** line, by its
1172-
> package name (`` - FROM `<package>` TO … ``). Its exports take no line of their own,
1173-
> and a FROM line naming one is out of range, as any operand the marker does not name is.
1174+
> names it in code (comment spans masked, as `metadataSurfaceReference` reads): an
1175+
> import specifier, by its root or a subpath, or any other occurrence of the package
1176+
> name, a string value included.
1177+
> 3. **from-operands-named**: the FROM → TO block names the package on a single line, not
1178+
> one per export, by its package name (`` - FROM `<package>` TO … ``). Its exports take
1179+
> no line of their own, and a FROM line naming one is out of range, as any operand the
1180+
> marker does not name is.
11741181
>
11751182
> An export or member operand on a package deleted at HEAD stays refused at predicate 1,
11761183
> and the refusal names the `<package>#*` spelling. Nothing else about the category

‎scripts/check-adr-0087-registration.mjs‎

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
463463
'REX-L: the compiler is loaded only when a marker names the category': 6,
464464
'WS (#26220): ui workspace packages, `.tsx` modules, aliases and shims': 11,
465465
'IX (#26220): predicate 4 reads an `any` index signature; the entry may be `src/index.tsx`': 52,
466-
'WP (#26220): a deleted published package is retired whole, `<package>#*`': 50,
466+
'WP (#26220): a deleted published package is retired whole, `<package>#*`': 55,
467467
'G6: a changeset that was ALREADY breaking at base is inherited': 1,
468468
'R15: a changeset RENAMED AND turned breaking in the same commit': 5,
469469
'G10: a PURE rename of an ALREADY-breaking stock changeset': 3,
@@ -4796,26 +4796,36 @@ function verifyWholePackageRetired(pkg, { base, head, cwd, packagesAt, kit }) {
47964796

47974797
/**
47984798
* Predicate 2 for a whole package: the first metadata surface OUTSIDE `pkgDir` whose
4799-
* code imports `pkg` (its root or a subpath) -- `from '…'`, a bare `import '…'`,
4800-
* `import('…')` or `require('…')` -- or null. Read with comment spans blanked, as
4801-
* `metadataSurfaceReference` reads: a docblock naming the package reaches nothing.
4799+
* CODE names `pkg`, or null -- D9's "no metadata surface references it in code",
4800+
* read as `metadataSurfaceReference` reads an export: comment spans blanked, string
4801+
* literals left standing. A package name can sit in code only inside a string, so
4802+
* any occurrence counts -- a plugin id or package reference held as a VALUE as much
4803+
* as an import. An import specifier (`from '…'`, a bare `import '…'`, `import('…')`,
4804+
* `require('…')`, by the root or a subpath) is the case reported as `import`; every
4805+
* other occurrence is a `mention`. A name that merely starts with `pkg`
4806+
* (`plugin-xy` for `plugin-x`) is not it.
48024807
*
48034808
* @param {string} pkg
48044809
* @param {string} pkgDir the package's directory, no trailing slash
48054810
* @param {Map<string, string>} surfaceTexts path -> text of every metadata surface
4806-
* @returns {{ hitPath: string, line: string } | null}
4811+
* @returns {{ kind: 'import'|'mention', hitPath: string, line: string } | null}
48074812
*/
4808-
export function packageImporter(pkg, pkgDir, surfaceTexts) {
4813+
export function packageReference(pkg, pkgDir, surfaceTexts) {
48094814
const name = pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
48104815
const spec = new RegExp(`(?:\\bfrom\\s*|\\bimport\\s*\\(\\s*|\\brequire\\s*\\(\\s*|^\\s*import\\s+)(['"])${name}(?:/[^'"\\n]*)?\\1`, 'm');
4816+
const mention = new RegExp(`(?<![\\w@./-])${name}(?![\\w.-])`);
4817+
// `maskComments` blanks in place, so an offset in `code` is the same offset in the file.
4818+
const lineAt = (raw, code, at) => {
4819+
const end = code.indexOf('\n', at);
4820+
return raw.slice(code.lastIndexOf('\n', at) + 1, end < 0 ? undefined : end).trim();
4821+
};
48114822
for (const [hitPath, rawText] of surfaceTexts) {
48124823
if (hitPath.startsWith(`${pkgDir}/`) || !rawText.includes(pkg)) continue;
48134824
const code = maskComments(rawText);
4814-
const m = spec.exec(code);
4815-
if (!m) continue;
4816-
// `maskComments` blanks in place, so an offset in `code` is the same offset in the file.
4817-
const end = code.indexOf('\n', m.index);
4818-
return { hitPath, line: rawText.slice(code.lastIndexOf('\n', m.index) + 1, end < 0 ? undefined : end).trim() };
4825+
const imp = spec.exec(code);
4826+
if (imp) return { kind: 'import', hitPath, line: lineAt(rawText, code, imp.index) };
4827+
const m = mention.exec(code);
4828+
if (m) return { kind: 'mention', hitPath, line: lineAt(rawText, code, m.index) };
48194829
}
48204830
return null;
48214831
}
@@ -5127,7 +5137,7 @@ export function verifyRuntimeExportRetired(refs, { base, head, cwd, body, packag
51275137
const t = `\`runtime-export-retired ${op.ref}\` [predicate 2: no-metadata-surface]`;
51285138
if (op.whole) {
51295139
// The same question, asked of the PACKAGE: none of its files is a metadata
5130-
// surface, and no metadata surface outside it imports it.
5140+
// surface, and no metadata surface outside it names it in code.
51315141
let own = [];
51325142
try { own = git(['ls-tree', '-r', '--name-only', base, '--', `${r.pkgDir}/`], cwd).split('\n').map((s) => s.trim()).filter(Boolean); } catch { /* listed below as none */ }
51335143
const site = own.find((f) => metadataSurfaceKind(f) !== null);
@@ -5140,11 +5150,12 @@ export function verifyRuntimeExportRetired(refs, { base, head, cwd, body, packag
51405150
continue;
51415151
}
51425152
metadataTexts ??= showManyOrNull(base, surfacePaths, cwd);
5143-
const importer = packageImporter(op.pkg, r.pkgDir, metadataTexts);
5144-
if (importer) {
5153+
const hit = packageReference(op.pkg, r.pkgDir, metadataTexts);
5154+
if (hit) {
51455155
problems.push(
5146-
`${t} is false: ${importer.hitPath} (${metadataSurfaceKind(importer.hitPath)}) IMPORTS \`${op.pkg}\`:\n` +
5147-
` ${importer.line.slice(0, 140)}\n` +
5156+
`${t} is false: ${hit.hitPath} (${metadataSurfaceKind(hit.hitPath)}) ` +
5157+
`${hit.kind === 'import' ? 'IMPORTS' : 'names'} \`${op.pkg}\` IN CODE:\n` +
5158+
` ${hit.line.slice(0, 140)}\n` +
51485159
' A package a metadata surface reaches HAS a metadata surface (comment spans are already excluded).',
51495160
);
51505161
continue;
@@ -8217,6 +8228,10 @@ function selfTest() {
82178228
red('WP-R10 a metadata surface OUTSIDE the package imports it at the merge base', wp({
82188229
base: { 'packages/spec/src/ui/widget.zod.ts': `import { Widget } from '${WP_PKG}';\nexport const W = Widget;\n` },
82198230
}), [P2, /packages\/spec\/src\/ui\/widget\.zod\.ts \(a Zod schema\) IMPORTS `@object-ui\/plugin-x`/]);
8231+
red('WP-R14 a metadata surface OUTSIDE the package names it as a string VALUE (no import) at the merge base', wp({
8232+
base: { 'packages/platform-objects/src/ui/widget-host.object.ts': `export const WidgetHost = { name: 'widget_host', plugin: '${WP_PKG}' };\n` },
8233+
}), [P2, /packages\/platform-objects\/src\/ui\/widget-host\.object\.ts \(an object definition\) names `@object-ui\/plugin-x` IN CODE/,
8234+
/plugin: '@object-ui\/plugin-x'/]);
82208235
red('WP-R11 no FROM line names the package', wp({ cs: WP_CS('Nothing else to do.') }),
82218236
[/the named operand `@object-ui\/plugin-x#\*` has no FROM side/, /- FROM `@object-ui\/plugin-x` TO/]);
82228237
red('WP-R12 one FROM line per EXPORT instead of the package line', wp({ cs: WP_CS('- FROM `Widget` TO nothing: delete the import.') }),
@@ -8236,10 +8251,17 @@ function selfTest() {
82368251
['packages/spec/src/b.zod.ts', "import { X } from '@object-ui/plugin-xy';\n"],
82378252
['ui/packages/plugin-x/src/c.zod.ts', `import '${WP_PKG}';\n`],
82388253
]);
8239-
assert(packageImporter(WP_PKG, 'ui/packages/plugin-x', texts)?.hitPath === 'packages/spec/src/a.zod.ts',
8240-
'WP-U3: a SUBPATH import is an import of the package; the package\'s own files are skipped');
8241-
assert(packageImporter(WP_PKG, 'ui/packages/plugin-x', new Map([...texts].slice(1))) === null,
8254+
const u3 = packageReference(WP_PKG, 'ui/packages/plugin-x', texts);
8255+
assert(u3?.hitPath === 'packages/spec/src/a.zod.ts' && u3.kind === 'import',
8256+
`WP-U3: a SUBPATH import is an import of the package; the package's own files are skipped (${JSON.stringify(u3)})`);
8257+
assert(packageReference(WP_PKG, 'ui/packages/plugin-x', new Map([...texts].slice(1))) === null,
82428258
'WP-U4: a package whose name merely STARTS with the name (`plugin-xy`) is not the package');
8259+
const u5 = packageReference(WP_PKG, 'ui/packages/plugin-x', new Map([
8260+
['packages/spec/src/c.zod.ts', `// '${WP_PKG}' in a comment\nexport const C = { from: 'x' };\n`],
8261+
['packages/spec/src/d.zod.ts', `export const D = { plugin: '${WP_PKG}' };\n`],
8262+
]));
8263+
assert(u5?.kind === 'mention' && u5.hitPath === 'packages/spec/src/d.zod.ts' && u5.line === `export const D = { plugin: '${WP_PKG}' };`,
8264+
`WP-U5: a string VALUE naming the package is a reference in code, reported with its line; a comment is not (${JSON.stringify(u5)})`);
82438265
}
82448266

82458267
// ---- G6: a changeset that was ALREADY breaking at base is inherited -------

0 commit comments

Comments
 (0)