Skip to content

Commit da4f614

Browse files
os-billclaude
andauthored
docs(spec): V3_MIGRATION_GUIDE announced a relocation that never happened, and the census gains a standing unpublished-subpath control (#18866)
Fixes #18753 Clause-②: yes (widening) Two halves, both required by the dispatch: (A) make the document true, and (B) turn this card into a fixture the census instrument cannot lose when the document is repaired. All readings below were taken in this worktree at base `62b114f8b8` (the seat's readings were taken at `be7aeb8275`, so every one was re-measured rather than inherited). Commit cited for the gate and lint runs: `8ac750cfe9`. ## What the re-measurement found, and where it departs from the dispatch The card framed one fork: repoint the specifier, or declare the subpath. The dispatching seat had already shown route (1) and route (2) are both unavailable for two of the three. Re-measuring confirmed that and added the cause: | Claim in the guide | Measured at `62b114f8b8` | |---|---| | helpers "moved to `@objectstack/core/errors`" / `@objectstack/core/plugin` | **False.** `466f32ea22` ("v3.0: Remove all deprecated items") **deleted** them: 25 removed lines naming the three symbols, **0 added** lines naming them. It created neither destination. | | the three symbols exist somewhere | **No.** Zero `.ts`/`.tsx` files repo-wide name `createErrorResponse`, `getHttpStatusForCategory` or `definePlugin`. Bright control on the same `git grep`: `defineStack` reads **368** files. | | `@objectstack/core` serves those subpaths | **No.** Its `exports` map has exactly two entries, `.` and `./logger`. | | "`ErrorResponseSchema`, `PluginDefinitionSchema` etc. remain in `@objectstack/spec`" | **Half false.** `ErrorResponseSchema (const)` is at `api-surface/api.json:364`; `PluginDefinitionSchema` reads **0** across every `api-surface/*.json` file. | The guide was created `2026-02-11`; the removal landed `2026-02-12`. It predicted a relocation one day before a commit that did not perform it, and ticked the checklist anyway. So the defect is not three unresolvable imports — it is **a false completion declaration**, and there is no resolving specifier to substitute because nothing was ever published to replace these. ### Departure: line 28 keeps `@objectstack/spec/hub`, deliberately The dispatch prescribed repointing line 28 at `@objectstack/spec/system`. **This PR does not do that**, on the dispatch's own single criterion — *after the repair, every sentence the document says must be true of this tree today*. Line 28 sits inside a block headed `// ❌ v2.x (deprecated, removed in v3.0)`, and the ✅ block directly beneath it already says `import { TenantSchema } from '@objectstack/spec/system';`. Repointing line 28 would make the ❌ and ✅ blocks identical and have the guide assert that the **current, supported** import is the one v3.0 removed. That is a worse falsehood than the one being repaired, and it deletes the document's only statement of what to migrate *away from*. Two measurements make the case stronger than a style preference: 1. **Line 28 contributes nothing to the census number anyway.** Its block reports `TS1109` only — a syntax error from `parse({ ... })` — so it lands in pass 1's unparseable set and tsc never type-checks it. `@objectstack/spec/hub` produces **no TS2307 at all** in the corpus today. The card's "measured as TS2307" holds for two of the three specifiers, not three. 2. Both remaining ❌ blocks (sections 2 and 4) have the same property: they are true depictions of removed APIs. The instrument's own docblock already concedes this category for `CHANGELOG.md` — "before/after migration snippets that deliberately show removed APIs. A failing block here is frequently CORRECT documentation." What the ❌ blocks did get is an explicit in-block disclaimer, modelled on the shape `content/docs/protocol/kernel/plugin-spec.mdx` already uses in this repository (a warn callout naming what does not exist, repeated as an in-block comment at the point of use): each now says it is shown so you can find and delete it, and must not be copied. ⇒ If the seat prefers the prescription as written, or prefers de-fencing the ❌ blocks into prose so they leave the TypeScript population entirely, both are one-line follow-ups. They are recorded as open questions rather than taken unilaterally. ## (A) What changed in the document - Section 2 renamed "Runtime Logic Extraction" to "Runtime Helper Removal" — no inbound anchor references it — and now states the removal with **no destination**, above a callout naming the relocation that never happened. - The table's "New Location" column is now "v3.0 disposition", reading "Removed — no replacement export" on all three rows. - The ✅ code block is gone. There is no ✅ counterpart to paste. In its place: prose pointing at what actually exists — `ErrorResponseSchema` still describes the envelope, and a plugin is an ordinary object or class matching the `Plugin` interface exported from `@objectstack/core` via `export * from './types.js'`. - The two checklist items are **unticked** and reworded. The tick was the false claim; unticking it is the first half of the repair, the wording the second. - The note drops `PluginDefinitionSchema` and names it as exported from no entrypoint. ⛔ Nothing was pointed at `packages/runtime/src/error-envelope.ts` (`buildApiError` / `apiErrorResponse`), although it is the closest live thing: it is **not** re-exported from `@objectstack/runtime`'s index, so naming it would repeat exactly the defect this card is about. ## (B) The synthetic control leg `UNPUBLISHED_SUBPATH_CONTROL` reconstructs the three specifiers line for line as the guide carried them before this repair, alongside a frozen `UNPUBLISHED_SUBPATH_SPECIFIERS` roster the block is held equal to by a battery. It is synthetic for the reason `FIRING_CONTROL` is: a control that IS the live document disarms itself the moment the document is repaired, and the fourth instance in the next guide would be just as green. It carries no elision marker and no syntax error, both load-bearing — a marker would let the disqualified exclusion reading skip it, and a syntax error would put it in pass 1's unparseable set where the three TS2307s it exists to produce are never reported. That second point is not theoretical: it is precisely what hides line 28 from the corpus, so **the synthetic leg measures a specifier the live document never did.** ### The refusal-list question, and its cost **Decision: the leg joins `controlProblems()`.** When it misbehaves, the whole census refuses. - What it guards is the **tolerance rule**, not the corpus. Widen `classifyDiagnostic` until an `@objectstack/*` TS2307 is forgiven and every unresolvable documented import in the repository goes quiet at once. `FIRING_CONTROL` is a TS2341 and cannot notice; before this PR the census had **no** standing control for the `unpublished-subpath` family — the family this card is an instance of. - Self-test-only was the alternative, and it leaves that hole open: the census would keep printing numbers nobody could trust. - **The cost, stated plainly:** if one of the three subpaths is ever genuinely published, the leg stops producing TS2307 for it and **every census refuses** until someone edits the fixture. That is the same exposure `FIRING_CONTROL` already carries — make `ObjectKernel.logger` public and it stops producing TS2341 — and it is answered the same way: the refusal text names the remedy (repoint at a subpath still in no `exports` entry, ⛔ never delete the leg, which takes the other two guards with it) instead of leaving a stale fixture to be diagnosed. Five batteries register against it and `SELF_TEST_BATTERY_FLOOR` rises **30 → 35**. Two of the five drive `controlProblems()` directly, and each first asserts a healthy control trio reports **zero** problems before asserting a broken one reports some — otherwise a `controlProblems` that always complained would pass. ## Evidence **LIT — the leg fires, proven by ablation.** Each ablation asserted the tree equalled HEAD first, proved the mutation reached disk by occurrence count and changed blob hash, restored from HEAD under a trap, and proved restoration by `git diff HEAD` being empty and the blob matching `12ec6b3955b4e6fdff880705eee5a67c9b457bf6`. | Ablation | `--self-test` | census | |---|---|---| | widen tolerance, `forgiven: false` to `true` on the `@objectstack/` branch | **exit 1**, 2 failures | **exit 1** — refuses, naming all three specifiers as `forgiven=true` | | drop the five roster rows | **exit 1**, 6 failures, floor reports "declares 30 batteries, below the pinned 35" | not exercised | | drop it from `controlUnits()` | not exercised | **exit 1** — "UNPUBLISHED_SUBPATH_CONTROL did not extract to a block" | **DARK — nothing else moved.** `--self-test` 30 cases / 30 batteries before, **35 / 35** after, exit 0; a roster diff against the base proves **0** of the original 30 batteries were dropped. `FIRING_CONTROL` is byte-identical in behaviour across both censuses: "reports 5 diagnostic(s) including TS2341 at block line 13", elision marker still present. `GREEN_CONTROL` compiles clean in both. **Census, before and after** (full build first — both readings share one build, so only the document differs): | | before | after | |---|---|---| | `unpublished-subpath` family | `8 diagnostics in 7 blocks` | `6 diagnostics in 6 blocks` | | this file's share of that | 2 (`@objectstack/core/errors`, `@objectstack/core/plugin`, both in the block at line 72) | **0** | | `V3_MIGRATION_GUIDE.md` | `7 blocks / 5 RAW / 5 TOLERANT` | `7 blocks / 5 RAW / 5 TOLERANT` | | hand-written stratum | 282 blocks / 195 RAW / 76 TOLERANT | unchanged | The dispatch expected `5 TOLERANT` to fall. **It did not, and that is the correct outcome** — measured per block rather than asserted. The repaired block went from 11 diagnostics to 3: both TS2307 gone, and all five TS2300 "duplicate identifier" gone too, since those were an artefact of the ❌ and ✅ lines importing the same names into one block. What remains is `TS2724` + `TS2305` x2 — "no exported member" against `@objectstack/spec/api` and `@objectstack/spec/kernel` — which is the block correctly reporting that it depicts symbols v3.0 removed. Every one of this file's five failing blocks is a ❌ example, and failing is what a ❌ example does. The remaining 6 in the family are all `packages/spec/CHANGELOG.md` naming `@objectstack/spec/cloud` — release-owned history, out of scope here. **Gates.** Derived with `scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the merge base, re-derived after `git fetch origin main` and **identical**: **65 families, 65 run, 65 exit 0**, reconciled back through `--ran` with exit codes recorded (a derived zero, not a claimed one). `pnpm --filter @objectstack/spec typecheck` exit 0. **Lint.** Not narrowed — the repo-wide scan fitted in budget: `eslint . --no-inline-config --format json` linted **6848** files with **0** messages, exit 0, at `8ac750cfe9`. (Type-aware linting is not enabled in `eslint.config.mjs`, so the narrowing argument was available and turned out to be unnecessary.) **Publish surface.** `V3_MIGRATION_GUIDE.md` is matched by **0** `files[]` entries in any package manifest (positive control: `README.md` **is** in `@objectstack/spec`'s `files[]`), and `scripts/` belongs to the private root package. So nothing this diff touches publishes, which is the `skip-changeset` criterion, and `lint.yml` names this exact case in its own prose: "this PR edits a CI-internal script is the textbook `skip-changeset` case — such a PR releases nothing". ⚠️ The `Clause-②: yes (widening)` line above is the dispatching seat's mechanical-floor declaration, carried verbatim as instructed; it is a routing declaration, not this PR's measurement, and the measurement above reads the other way. Flagged for the seat's in-seat re-review rather than changed here. ## Acceptance notes Observed while measuring, ⛔ not filed and ⛔ not repaired here: - The `unpublished-subpath` family count is a **lower bound**, not a total. A syntax error anywhere in a block sends it to pass 1's unparseable set, where no semantic diagnostic is ever reported — which is exactly why line 28's `@objectstack/spec/hub` is invisible. 18 hand-written blocks are syntax-only-unforgiven and could each be masking more. The instrument documents the two-pass mechanism and calls it honest; what is not stated is that the family totals inherit a floor from it. Successor: #18715's wiring letter needs that caveat beside the number. - `packages/spec/src/benchmark.bench.ts:120` calls `(Kernel as any).PluginDefinitionSchema.parse(validPlugin)` on a symbol exported from no entrypoint. The `as any` is what lets it compile. Successor: whoever next touches that bench. - `packages/spec/V3_MIGRATION_GUIDE.md` is a before/after migration guide sitting in the census's `handwritten` stratum, whose contract reads "a failing block here is a defect an author can repair". For this document's ❌ blocks that is false, and after this repair all five of its remaining TOLERANT failures are correct documentation. This is a real input to #18715's wiring decision, since it is part of what a gate would be asked to hold at zero. - The same never-performed relocation is asserted in `packages/spec/DEVELOPMENT_PLAN.md:463-465`, `packages/spec/ZOD_SCHEMA_AUDIT_REPORT.md` and root `CHANGELOG.md:2289-2290`. Left untouched by the dispatch's read-only fence; the seat files these. - The document's title, "Target Release: Q2 2026" and "Last Updated: 2026-02-11" sit against packages at version 17.4.0. Not touched: out of this card's scope, and no reader is misdirected to an unresolvable import by it. ⛔ `packages/spec/package.json` and `packages/core/package.json` were not opened for editing. Route (2) was never needed: it is impossible for two of the three specifiers, since a module that does not exist cannot be declared in an `exports` map. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 36583e9 commit da4f614

2 files changed

Lines changed: 230 additions & 19 deletions

File tree

‎packages/spec/V3_MIGRATION_GUIDE.md‎

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
| Category | Change | Impact |
1212
|----------|--------|--------|
1313
| Hub Module Removal | `Hub.*` namespace removed from barrel exports | Medium |
14-
| Runtime Logic Extraction | `createErrorResponse()`, `getHttpStatusForCategory()`, `definePlugin()` removed from spec | Medium |
14+
| Runtime Helper Removal | `createErrorResponse()`, `getHttpStatusForCategory()`, `definePlugin()` removed from spec, with no replacement export | Medium |
1515
| Deprecated Field Removal | `location` (singular) removed from ActionSchema | Low |
1616
| Deprecated Schema Aliases | `RealtimePresenceStatus`, `RealtimeAction`, `RateLimitSchema` removed | Low |
1717

@@ -24,7 +24,10 @@
2424
The `hub/` directory has been removed. Previously it re-exported schemas from `system/` and `kernel/`:
2525

2626
```typescript
27-
// ❌ v2.x (deprecated, removed in v3.0)
27+
// ❌ v2.x — REMOVED in v3.0, shown so you can find it in your own code and
28+
// delete it. ⛔ Do not copy this block: `@objectstack/spec/hub` is in no
29+
// `exports` entry and the `Hub` namespace is off the root barrel, so neither
30+
// line resolves for any consumer today. The ✅ replacement is below.
2831
import { TenantSchema } from '@objectstack/spec/hub';
2932
import { Hub } from '@objectstack/spec';
3033
const tenant = Hub.TenantSchema.parse({ ... });
@@ -52,33 +55,51 @@ import { PluginRegistryEntrySchema } from '@objectstack/spec/kernel';
5255

5356
---
5457

55-
## 2. Runtime Logic Extraction
58+
## 2. Runtime Helper Removal
5659

5760
### What Changed
5861

59-
Helper functions that contain runtime logic have been moved from `@objectstack/spec` to `@objectstack/core`. The spec package should contain only schema definitions.
62+
Three helper functions that carried runtime logic were **removed** from `@objectstack/spec`. The spec package should contain only schema definitions.
63+
64+
> **⚠️ Removed, ⛔ not relocated — there is no replacement import.** An earlier
65+
> revision of this guide said these helpers had "moved to
66+
> `@objectstack/core/errors`" and `@objectstack/core/plugin`. **That move never
67+
> happened.** `@objectstack/core` publishes exactly two entry points —
68+
> `@objectstack/core` and `@objectstack/core/logger` — so neither of those
69+
> subpaths resolves for any consumer, and the three symbols themselves exist
70+
> nowhere in the codebase. The v3.0 change deleted them outright.
6071
6172
### Functions Removed from Spec
6273

63-
| Function | Previous Location | New Location |
64-
|----------|-------------------|-------------|
65-
| `createErrorResponse()` | `api/errors.zod.ts` | `@objectstack/core/errors` |
66-
| `getHttpStatusForCategory()` | `api/errors.zod.ts` | `@objectstack/core/errors` |
67-
| `definePlugin()` | `kernel/plugin.zod.ts` | `@objectstack/core/plugin` |
74+
| Function | Previous Location | v3.0 disposition |
75+
|----------|-------------------|------------------|
76+
| `createErrorResponse()` | `api/errors.zod.ts` | Removed — no replacement export |
77+
| `getHttpStatusForCategory()` | `api/errors.zod.ts` | Removed — no replacement export |
78+
| `definePlugin()` | `kernel/plugin.zod.ts` | Removed — no replacement export |
6879

6980
### How to Migrate
7081

82+
Delete the import and inline what you were using. There is no ✅ counterpart to
83+
paste, because nothing was published to replace these:
84+
85+
- **`createErrorResponse()` / `getHttpStatusForCategory()`** — `ErrorResponseSchema` still
86+
describes the error envelope, so build the object you need and validate it against that
87+
schema directly.
88+
- **`definePlugin()`** — a plugin is an ordinary object or class matching the `Plugin`
89+
interface exported from `@objectstack/core` (`init` / `start` / `destroy`); no wrapper
90+
function is involved.
91+
7192
```typescript
72-
// ❌ v2.x (deprecated, removed in v3.0)
93+
// ❌ v2.x — REMOVED in v3.0, shown so you can find these imports in your own
94+
// code and delete them. ⛔ Do not copy this block.
7395
import { createErrorResponse, getHttpStatusForCategory } from '@objectstack/spec/api';
7496
import { definePlugin } from '@objectstack/spec/kernel';
75-
76-
// ✅ v3.0
77-
import { createErrorResponse, getHttpStatusForCategory } from '@objectstack/core/errors';
78-
import { definePlugin } from '@objectstack/core/plugin';
7997
```
8098

81-
> **Note:** The schemas `ErrorResponseSchema`, `PluginDefinitionSchema` etc. remain in `@objectstack/spec`. Only the runtime helper functions are moved.
99+
> **Note:** `ErrorResponseSchema` remains in `@objectstack/spec`, exported from the
100+
> `@objectstack/spec/api` entrypoint. The removal took the runtime helper functions only.
101+
> ⛔ `PluginDefinitionSchema` is **not** among the survivors — it is exported from no
102+
> entrypoint today.
82103
83104
---
84105

@@ -147,8 +168,8 @@ import { EventBusConfigSchema, EventSchema, EventPriority } from '@objectstack/s
147168
## Migration Checklist
148169

149170
- [x] Replace all `Hub.*` imports with direct `system/` or `kernel/` imports
150-
- [x] Remove `createErrorResponse()` / `getHttpStatusForCategory()` from spec (moved to `@objectstack/core/errors`)
151-
- [x] Remove `definePlugin()` from spec (moved to `@objectstack/core/plugin`)
171+
- [ ] Drop `createErrorResponse()` / `getHttpStatusForCategory()` — removed from spec in v3.0 and ⛔ **not** relocated; there is no replacement import (see §2)
172+
- [ ] Drop `definePlugin()` — removed from spec in v3.0 and ⛔ **not** relocated; there is no replacement import (see §2)
152173
- [x] Replace `location` with `locations` in ActionSchema definitions
153174
- [x] Replace `RealtimePresenceStatus` with `PresenceStatus`
154175
- [x] Replace `RealtimeAction` with `RealtimeRecordAction`

‎scripts/measure-markdown-ts-blocks.mjs‎

Lines changed: 192 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,65 @@ export const FIRING_CONTROL = [
419419
'',
420420
].join('\n');
421421

422+
/**
423+
* The three specifiers UNPUBLISHED_SUBPATH_CONTROL exists to keep COUNTED. The
424+
* block below and this list are held equal by a self-test battery, so editing
425+
* one without the other is a failure rather than a silent narrowing.
426+
*/
427+
export const UNPUBLISHED_SUBPATH_SPECIFIERS = Object.freeze([
428+
'@objectstack/spec/hub',
429+
'@objectstack/core/errors',
430+
'@objectstack/core/plugin',
431+
]);
432+
433+
/**
434+
* MUST be reported failing, with a TS2307 on EACH of the three specifiers
435+
* above, every one of them classified `unpublished-subpath` and forgiven by
436+
* nothing.
437+
*
438+
* Reconstructed line-for-line from `packages/spec/V3_MIGRATION_GUIDE.md` as it
439+
* read BEFORE #18753 repaired it. That document announced a migration that
440+
* never happened: `466f32ea22` deleted `createErrorResponse`,
441+
* `getHttpStatusForCategory` and `definePlugin` from spec and created neither
442+
* destination, so the guide told a reader to import from subpaths their
443+
* packages publish in no `exports` entry.
444+
*
445+
* ⭐ SYNTHETIC ON PURPOSE, and it is the whole reason this fixture exists. A
446+
* control that IS the live document disarms itself the moment the document is
447+
* repaired — the census number falls, and the fourth instance in the next guide
448+
* is just as green. FIRING_CONTROL was reconstructed from PR #18712's diff for
449+
* exactly this reason; this is the same move for the `unpublished-subpath`
450+
* family, which FIRING_CONTROL (a TS2341) does not cover at all.
451+
*
452+
* ⭐ WHAT IT GUARDS IS THE TOLERANCE RULE, not the corpus. Widen
453+
* `classifyDiagnostic` until a `@objectstack/*` TS2307 is forgiven and every
454+
* unresolvable documented import in this repository goes quiet at once — with
455+
* no other control able to notice. That is why it sits on the refusal list
456+
* beside the other two instead of firing only under `--self-test`.
457+
*
458+
* ⚠️ THE COST, stated because it is real: if one of the three subpaths is ever
459+
* genuinely published, this control stops producing TS2307 for it and EVERY
460+
* census refuses until someone edits the fixture. That is the same exposure
461+
* FIRING_CONTROL already carries (make `ObjectKernel.logger` public and it
462+
* stops producing TS2341), and it is answered the same way — the refusal text
463+
* names the remedy instead of leaving a stale fixture to be diagnosed.
464+
*
465+
* ⛔ It carries NO elision marker and NO syntax error, both load-bearing: a
466+
* marker would let the (disqualified) exclusion reading skip it, and a syntax
467+
* error would land it in pass 1's unparseable set, where tsc never type-checks
468+
* it and the three TS2307s it exists to produce would never be reported at all.
469+
*/
470+
export const UNPUBLISHED_SUBPATH_CONTROL = [
471+
'# unpublished-subpath control',
472+
'',
473+
'```typescript',
474+
"import { TenantSchema } from '@objectstack/spec/hub';",
475+
"import { createErrorResponse, getHttpStatusForCategory } from '@objectstack/core/errors';",
476+
"import { definePlugin } from '@objectstack/core/plugin';",
477+
'```',
478+
'',
479+
].join('\n');
480+
422481
// ── Resolution: a `paths` map generated from each package's own exports map ──
423482

424483
/** Walk a conditional exports value for the `types` condition an importer sees. */
@@ -643,6 +702,14 @@ function controlUnits() {
643702
return [
644703
{ ...extractBlocks(GREEN_CONTROL)[0], file: 'control:green', index: 0, kind: 'control', control: 'green', stratum: 'control' },
645704
{ ...extractBlocks(FIRING_CONTROL)[0], file: 'control:firing', index: 0, kind: 'control', control: 'firing', stratum: 'control' },
705+
{
706+
...extractBlocks(UNPUBLISHED_SUBPATH_CONTROL)[0],
707+
file: 'control:unpublished-subpath',
708+
index: 0,
709+
kind: 'control',
710+
control: 'unpublished-subpath',
711+
stratum: 'control',
712+
},
646713
];
647714
}
648715

@@ -686,6 +753,37 @@ export function controlProblems(controls) {
686753
);
687754
}
688755
}
756+
const subpath = controls.find((c) => c.control === 'unpublished-subpath');
757+
if (!subpath) problems.push('UNPUBLISHED_SUBPATH_CONTROL did not extract to a block — the extractor is broken.');
758+
else {
759+
for (const specifier of UNPUBLISHED_SUBPATH_SPECIFIERS) {
760+
const hit = subpath.diagnostics.find((d) => d.code === 2307 && d.specifier === specifier);
761+
if (!hit) {
762+
problems.push(
763+
`UNPUBLISHED_SUBPATH_CONTROL produced no TS2307 for '${specifier}'. Either the compile path no longer `
764+
+ 'reaches this fixture, or that subpath is now PUBLISHED. If it is published, this control is obsolete '
765+
+ 'for that specifier: repoint it at one that is still in no `exports` entry, in the same edit that '
766+
+ 'records why — ⛔ never by deleting the leg, which removes the guard for the other two with it.',
767+
);
768+
continue;
769+
}
770+
if (hit.family !== 'unpublished-subpath' || hit.forgiven) {
771+
problems.push(
772+
`UNPUBLISHED_SUBPATH_CONTROL's TS2307 for '${specifier}' classified as ${hit.family} / `
773+
+ `forgiven=${hit.forgiven}, not unpublished-subpath / forgiven=false. The tolerance rule has been `
774+
+ 'widened until a documented import that resolves for NO consumer of the published package is forgiven, '
775+
+ 'which silences this whole family of defect at once.',
776+
);
777+
}
778+
}
779+
if (subpath.raw && !subpath.tolerant) {
780+
problems.push(
781+
'The tolerance rule FORGAVE the unpublished-subpath control outright, so every unresolvable '
782+
+ '`@objectstack/*` import in the corpus is now invisible to the TOLERANT reading — the one number a gate '
783+
+ 'could be built on.',
784+
);
785+
}
786+
}
689787
return problems;
690788
}
691789

@@ -754,6 +852,11 @@ function report(units, controls, summary, files) {
754852
' FIRING_CONTROL also carries an elision marker, so the EXCLUSION reading SKIPS it. '
755853
+ 'That is why exclusion is reported below as disqualified rather than offered.',
756854
);
855+
console.log(
856+
` UNPUBLISHED_SUBPATH_CONTROL reports TS2307 on all ${UNPUBLISHED_SUBPATH_SPECIFIERS.length} of its specifiers `
857+
+ `(${UNPUBLISHED_SUBPATH_SPECIFIERS.join(', ')}), every one COUNTED as `
858+
+ 'unpublished-subpath — the tolerance rule still refuses to forgive an import that resolves for no consumer.',
859+
);
757860

758861
console.log('\nPOPULATION');
759862
console.log(` package roots scanned ....... ${findPackageRoots(PACKAGES_DIR).length}`);
@@ -858,6 +961,11 @@ const SELF_TEST_BATTERIES = Object.freeze({
858961
'fixture — FIRING_CONTROL extracts to one block that reads kernel.logger': 1,
859962
'fixture — FIRING_CONTROL carries the elision marker that disqualifies exclusion': 1,
860963
'fixture — GREEN_CONTROL extracts to one block importing @objectstack/core': 1,
964+
'fixture — UNPUBLISHED_SUBPATH_CONTROL extracts to one block whose specifiers are exactly UNPUBLISHED_SUBPATH_SPECIFIERS': 1,
965+
'fixture — UNPUBLISHED_SUBPATH_CONTROL carries no elision marker, so no reading can skip it': 1,
966+
'classify — every UNPUBLISHED_SUBPATH_SPECIFIERS entry is COUNTED as unpublished-subpath': 1,
967+
'refusal — a missing TS2307 on a listed specifier REFUSES the census': 1,
968+
'refusal — a FORGIVEN unpublished-subpath diagnostic REFUSES the census': 1,
861969
'population — stratumOf separates CHANGELOG.md from hand-written Markdown': 1,
862970
'population — isDepthOne separates a depth-1 package root from a nested one': 1,
863971
'exports — pickTypesTarget reads the types entry under the import condition': 1,
@@ -866,11 +974,28 @@ const SELF_TEST_BATTERIES = Object.freeze({
866974

867975
// Deleting an entry silences that battery's floor exactly as effectively as
868976
// zeroing it, so the roster's own size is pinned too.
869-
const SELF_TEST_BATTERY_FLOOR = 30;
977+
const SELF_TEST_BATTERY_FLOOR = 35;
870978

871979
const md = (...lines) => lines.join('\n');
872980

873981
function selfTest() {
982+
// Healthy stand-ins for the other two controls, so a refusal battery reads
983+
// only the problems the unpublished-subpath leg contributes — and asserts the
984+
// healthy trio reports NONE before asserting a broken one reports some, since
985+
// a `controlProblems` that always complained would otherwise pass.
986+
const healthyGreen = () => ({ control: 'green', raw: false, tolerant: false, elided: false, diagnostics: [] });
987+
const healthyFiring = () => ({
988+
control: 'firing',
989+
raw: true,
990+
tolerant: true,
991+
elided: true,
992+
diagnostics: [{ code: 2341, family: 'semantic', forgiven: false }],
993+
});
994+
const healthySubpathDiagnostics = () => UNPUBLISHED_SUBPATH_SPECIFIERS.map((specifier) => ({
995+
code: 2307, specifier, family: 'unpublished-subpath', forgiven: false,
996+
}));
997+
const subpathControl = (diagnostics) => ({ control: 'unpublished-subpath', raw: true, tolerant: true, elided: false, diagnostics });
998+
874999
const cases = [
8751000
{ label: 'extract — a plain ```ts fence is a block', run: () => (extractBlocks(md('```ts', 'const a = 1;', '```')).length === 1 ? null : 'expected 1 block') },
8761001
{ label: 'extract — a ```typescript fence is a block', run: () => (extractBlocks(md('```typescript', 'const a = 1;', '```'))[0]?.lang === 'typescript' ? null : 'expected a typescript block') },
@@ -968,6 +1093,66 @@ function selfTest() {
9681093
return specifiersIn(b[0].code).includes('@objectstack/core') ? null : 'the green control stopped importing anything';
9691094
},
9701095
},
1096+
{
1097+
label: 'fixture — UNPUBLISHED_SUBPATH_CONTROL extracts to one block whose specifiers are exactly UNPUBLISHED_SUBPATH_SPECIFIERS',
1098+
run: () => {
1099+
const b = extractBlocks(UNPUBLISHED_SUBPATH_CONTROL);
1100+
if (b.length !== 1) return `expected 1 block, got ${b.length}`;
1101+
const found = specifiersIn(b[0].code);
1102+
const expected = [...UNPUBLISHED_SUBPATH_SPECIFIERS];
1103+
return found.length === expected.length && expected.every((s) => found.includes(s))
1104+
? null
1105+
: `the block imports ${JSON.stringify(found)} but the roster is ${JSON.stringify(expected)} — editing one without the other narrows the guard silently`;
1106+
},
1107+
},
1108+
{
1109+
label: 'fixture — UNPUBLISHED_SUBPATH_CONTROL carries no elision marker, so no reading can skip it',
1110+
run: () => (hasElisionMarker(extractBlocks(UNPUBLISHED_SUBPATH_CONTROL)[0].code)
1111+
? 'the fixture gained an elision marker, so the exclusion reading would skip the one leg guarding this family'
1112+
: null),
1113+
},
1114+
{
1115+
label: 'classify — every UNPUBLISHED_SUBPATH_SPECIFIERS entry is COUNTED as unpublished-subpath',
1116+
run: () => {
1117+
for (const specifier of UNPUBLISHED_SUBPATH_SPECIFIERS) {
1118+
const verdict = classifyDiagnostic({
1119+
code: 2307,
1120+
message: `Cannot find module '${specifier}' or its corresponding type declarations.`,
1121+
});
1122+
if (verdict.family !== 'unpublished-subpath' || verdict.forgiven) {
1123+
return `${specifier} classified ${verdict.family} / forgiven=${verdict.forgiven}`;
1124+
}
1125+
}
1126+
return null;
1127+
},
1128+
},
1129+
{
1130+
label: 'refusal — a missing TS2307 on a listed specifier REFUSES the census',
1131+
run: () => {
1132+
const healthy = controlProblems([healthyGreen(), healthyFiring(), subpathControl(healthySubpathDiagnostics())]);
1133+
if (healthy.length !== 0) return `the healthy control trio already reported ${healthy.length} problem(s): ${healthy[0]}`;
1134+
const dropped = UNPUBLISHED_SUBPATH_SPECIFIERS[1];
1135+
const problems = controlProblems([
1136+
healthyGreen(),
1137+
healthyFiring(),
1138+
subpathControl(healthySubpathDiagnostics().filter((d) => d.specifier !== dropped)),
1139+
]);
1140+
return problems.some((p) => p.includes(dropped)) ? null : `dropping ${dropped} produced ${JSON.stringify(problems)}`;
1141+
},
1142+
},
1143+
{
1144+
label: 'refusal — a FORGIVEN unpublished-subpath diagnostic REFUSES the census',
1145+
run: () => {
1146+
const target = UNPUBLISHED_SUBPATH_SPECIFIERS[2];
1147+
const widened = healthySubpathDiagnostics().map((d) => (d.specifier === target
1148+
? { ...d, family: 'external-not-installed', forgiven: true }
1149+
: d));
1150+
const problems = controlProblems([healthyGreen(), healthyFiring(), subpathControl(widened)]);
1151+
return problems.some((p) => p.includes(target) && p.includes('forgiven=true'))
1152+
? null
1153+
: `forgiving ${target} produced ${JSON.stringify(problems)}`;
1154+
},
1155+
},
9711156
{ label: 'population — stratumOf separates CHANGELOG.md from hand-written Markdown', run: () => (stratumOf('packages/core/CHANGELOG.md') === 'changelog' && stratumOf('packages/core/README.md') === 'handwritten' ? null : 'stratum split broken') },
9721157
{ label: 'population — isDepthOne separates a depth-1 package root from a nested one', run: () => (isDepthOne('packages/core/README.md') && !isDepthOne('packages/plugins/plugin-auth/README.md') ? null : 'depth-1 sub-stratum broken') },
9731158
{
@@ -1077,7 +1262,12 @@ function main() {
10771262

10781263
const summary = summarise(units);
10791264
if (wantJson) console.log(JSON.stringify(toJson(units, controls, summary, files), null, 2));
1080-
else if (controlsOnly) console.log('✓ both controls behaved: GREEN compiles, FIRING reports TS2341 and survives the tolerance rule.');
1265+
else if (controlsOnly) {
1266+
console.log(
1267+
'✓ all three controls behaved: GREEN compiles, FIRING reports TS2341 and survives the tolerance rule, '
1268+
+ 'UNPUBLISHED_SUBPATH reports a COUNTED TS2307 on each of its three specifiers.',
1269+
);
1270+
}
10811271
else report(units, controls, summary, files);
10821272
}
10831273

0 commit comments

Comments
 (0)