1
0
Эх сурвалжийг харах

fix review findings: restrict export-import aliases to prose-only targets

Codex round-3: alias prose matches the gate's strength only when the
target's own contract is prose-only. An export-import alias to a
function, class, or namespace target (or an unresolvable one) is now
refused — those carry signature/member contracts the alias cannot
hold; export the declaration directly instead. Const/enum/interface/
type-alias targets keep the self-documentation contract.

Tests pin the refusal for function, class, and namespace targets;
module doc and RFC updated.
Tianyi Cui 2 сар өмнө
parent
commit
f7bd7e82d1

+ 1 - 1
docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md

@@ -17,7 +17,7 @@ The contract by declaration kind:
 - Exported classes need class-level prose; public methods (statics included — reachable on the exported name) follow the function contract; public properties and accessors need prose (a get/set pair is covered by the getter). Overload implementations are exempt — the signatures carry the docs.
 - Exported interfaces, type aliases, and enums need prose on the declaration; member-level enforcement is deliberately deferred (the highest-value member surface — seam service classes — is already under the cordis gate).
 - Exported namespaces recurse (inside an ambient `declare` namespace every member exports implicitly); the namespace itself needs prose only when it does not merge with a documented same-name declaration (the Config-namespace idiom documents the plugin once).
-- `declare module` / `declare global` bodies and `export … from` re-export statements are skipped: an augmentation is not an export of the package, and a re-exported definition is checked where it is defined. An `export import X = N.member` alias documents ITSELF — its target may be a non-exported namespace member no walk visits, so the "definition owns the doc" rationale does not hold for it.
+- `declare module` / `declare global` bodies and `export … from` re-export statements are skipped: an augmentation is not an export of the package, and a re-exported definition is checked where it is defined. An `export import X = N.member` alias documents ITSELF — its target may be a non-exported namespace member no walk visits — and only prose-only target kinds are gate-supported: a callable, class, or namespace target carries signature/member contracts the alias prose cannot hold, so the gate refuses it and demands the declaration be exported directly.
 - Everything else fails CLOSED: `export =` is refused outright, parameters the base never names keep their `@param` duty even as binding patterns, and an exported statement kind the dispatch does not recognize is itself a violation — no export form can pass unchecked by omission.
 
 Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's `this`/`next` exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked):

+ 13 - 0
packages/core/agent/tests/verify-export-jsdoc.spec.ts

@@ -338,6 +338,19 @@ describe('verify-export-jsdoc fail-closed forms (review round 1)', () => {
     ))).toEqual([])
   })
 
+  it('refuses an export-import alias to a callable, class, or namespace target', () => {
+    const refusal = /exported alias 'g' .* aliases a callable, class, or namespace target/
+    expect(collectExportJsdocViolations(make(
+      'namespace N {\n  export function f(x: number): number { return x }\n}\n/** Alias. */\nexport import g = N.f\n',
+    ))).toEqual([expect.stringMatching(refusal)])
+    expect(collectExportJsdocViolations(make(
+      'namespace N {\n  export class C {\n    run(x: number): number { return x }\n  }\n}\n/** Alias. */\nexport import g = N.C\n',
+    ))).toEqual([expect.stringMatching(refusal)])
+    expect(collectExportJsdocViolations(make(
+      'namespace N {\n  export namespace Sub {\n    export function f(x: number): number { return x }\n  }\n}\n/** Alias. */\nexport import g = N.Sub\n',
+    ))).toEqual([expect.stringMatching(refusal)])
+  })
+
   it('classifies wrapped function initializers and default exports (parens, satisfies)', () => {
     expect(collectExportJsdocViolations(make(
       'type Fn = (x: number) => number\n/** Wrapped. */\nexport const f = (((x: number): number => x)) satisfies Fn\n',

+ 22 - 6
scripts/verify-export-jsdoc.ts

@@ -64,8 +64,9 @@
  *   re-export statements with a module specifier (`export … from`) — the
  *   defining module is walked on its own, and external definitions are not
  *   ours to document. An `export import X = N.member` alias documents
- *   ITSELF (its target may be a non-exported namespace member no walk
- *   visits, so a skip would fail open).
+ *   ITSELF, and only prose-only target kinds are gate-supported: a callable,
+ *   class, or namespace target carries signature/member contracts the alias
+ *   cannot hold and is refused (export the declaration directly).
  * - Everything else fails CLOSED: `export =` is refused outright, and an
  *   exported statement kind the dispatch does not recognize is itself a
  *   violation, so no export form can pass unchecked by omission.
@@ -404,10 +405,25 @@ function checkDecl(
     return
   }
   if (ts.isImportEqualsDeclaration(stmt)) {
-    // An alias (`export import X = N.member`) is a distinct exported name and
-    // its target may be a non-exported namespace member no walk ever visits,
-    // so a blanket skip would fail open — the alias documents itself.
-    checkDescribed(`exported alias '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
+    const where = `exported alias '${prefix}${stmt.name.text}'${at(stmt)}`
+    // An alias is a distinct exported name whose target may be a non-exported
+    // namespace member no walk ever visits, so it documents ITSELF — which
+    // matches the gate's strength only for prose-only target kinds. A
+    // callable, class, or namespace target carries signature or member
+    // contracts the alias prose cannot hold: refuse those (fail closed) and
+    // demand the declaration be exported directly. An unresolvable target is
+    // refused for the same reason.
+    const sym = w.checker.getSymbolAtLocation(stmt.name)
+    const target = sym !== undefined && (sym.flags & ts.SymbolFlags.Alias) !== 0 ? w.checker.getAliasedSymbol(sym) : sym
+    const RICH_TARGETS = ts.SymbolFlags.Function | ts.SymbolFlags.Class | ts.SymbolFlags.ValueModule | ts.SymbolFlags.NamespaceModule
+    const rich = target === undefined
+      || (target.flags & RICH_TARGETS) !== 0
+      || w.checker.getTypeOfSymbol(target).getCallSignatures().length > 0
+    if (rich) {
+      w.violations.push(`${where} aliases a callable, class, or namespace target whose signature/member contract the alias cannot carry; export the declaration directly instead.`)
+      return
+    }
+    checkDescribed(where, rawJsDoc(w.text, stmt), w)
     return
   }
   // Fail CLOSED: an exported statement kind this dispatch does not recognize