Skip to content

Commit eefcbab

Browse files
committed
fix(lint): prevent false inner-declaration reports in strict code
Port the canonical declaration and block-function modes, derive strictness from parsed module/class/directive facts, and preserve positional ESLint option slots generically. Add shields for sloppy and strict scripts, ESM, root contexts, loop headers, static blocks, and public config typing. Constraint: Keep canonical ESLint option ordering and defaults without rule-name branches in config parsing. Rejected: Infer strictness from filenames or source substring searches | parser-owned module and directive facts are authoritative. Confidence: high Scope-risk: moderate Directive: Preserve single-object option payloads when extending positional rule settings. Tested: Static diff integrity and oracle comparison against the current ESLint implementation and documentation. Not-tested: Focused local Go and TypeScript validation intentionally deferred until three clean exhaustive PR reviews. Related: #511
1 parent a2d0fd4 commit eefcbab

18 files changed

Lines changed: 616 additions & 127 deletions

packages/lint/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ Source: [ESLint core rules](https://eslint.org/docs/latest/rules/).
260260
- [`no-func-assign`](https://github.com/samchon/ttsc/blob/master/tests/test-lint/src/cases/no-func-assign.ts): rejects reassignment of function declarations.
261261
- [`no-implicit-coercion`](https://github.com/samchon/ttsc/blob/master/tests/test-lint/src/cases/no-implicit-coercion.ts): reject common implicit-coercion idioms (`!!x`, `+x`, `"" + x`) in favor of the explicit `Boolean(x)` / `Number(x)` / `String(x)` conversions.
262262
- [`no-import-assign`](https://github.com/samchon/ttsc/blob/master/tests/test-lint/src/cases/no-import-assign.ts): rejects writes to imported bindings (including `ns.x = ...` for namespace imports).
263-
- [`no-inner-declarations`](https://github.com/samchon/ttsc/blob/master/tests/test-lint/src/cases/no-inner-declarations.ts): rejects function declarations nested in blocks.
263+
- [`no-inner-declarations`](https://github.com/samchon/ttsc/blob/master/tests/test-lint/src/cases/no-inner-declarations.ts): rejects block functions with legacy sloppy semantics; `"both"` also checks nested `var` declarations.
264264
- [`no-invalid-this`](https://github.com/samchon/ttsc/blob/master/tests/test-lint/src/cases/no-invalid-this.ts): reject `this` references outside any function-like, class method, or class-static-block context.
265265
- [`no-irregular-whitespace`](https://github.com/samchon/ttsc/blob/master/tests/test-lint/src/cases/no-irregular-whitespace.ts): rejects irregular whitespace.
266266
- [`no-iterator`](https://github.com/samchon/ttsc/blob/master/tests/test-lint/src/cases/no-iterator.ts): rejects `__iterator__`.

packages/lint/linthost/config.go

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,10 @@ func FindLintEntry(entries []PluginEntry) (*PluginEntry, error) {
8686
// rule name (e.g. "no-var").
8787
type RuleConfig map[string]Severity
8888

89-
// RuleOptionsMap captures the rule-specific options blob, keyed by rule
90-
// name. Severity-only rules never appear here. The values are the raw
91-
// JSON the user wrote in the second tuple slot of a `["error", { ... }]`
92-
// rule setting; each rule decodes the blob into its own option struct on
93-
// demand.
89+
// RuleOptionsMap captures the rule-specific options payload, keyed by rule
90+
// name. Severity-only rules never appear here. A single option slot preserves
91+
// its JSON shape; multiple positional slots are encoded as an array. Each rule
92+
// decodes the payload according to its public option type on demand.
9493
type RuleOptionsMap map[string]json.RawMessage
9594

9695
// ProjectRuleSetting is the global declaration resolved for one registered
@@ -479,9 +478,9 @@ func ParseRules(raw any) (RuleConfig, error) {
479478
}
480479

481480
// ParseRulesWithOptions accepts either a severity literal or a
482-
// `[severity, options]` tuple per rule and returns the severity map
481+
// `[severity, ...options]` tuple per rule and returns the severity map
483482
// alongside an options map keyed by rule name. The options map only
484-
// contains entries for rules whose configuration was the tuple form.
483+
// contains entries for rules whose configuration carries option slots.
485484
func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
486485
if raw == nil {
487486
return RuleConfig{}, RuleOptionsMap{}, nil
@@ -505,10 +504,10 @@ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
505504
return cfg, opts, nil
506505
}
507506

508-
// parseRuleEntry splits a rule entry into its severity and (optional) options
509-
// payload. Bare severity literals produce a nil options blob; `[severity]`
510-
// (no options) does the same; `[severity, options]` re-serializes the options
511-
// to JSON so each rule can decode it into its own struct later.
507+
// parseRuleEntry splits a rule entry into its severity and optional positional
508+
// options. A single option keeps its JSON shape for existing object-option
509+
// rules. Two or more options become a JSON array so canonical ESLint rules
510+
// with several positional slots can decode them without parser special cases.
512511
func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
513512
if tuple, ok := value.([]any); ok {
514513
if len(tuple) == 0 {
@@ -521,21 +520,14 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
521520
if len(tuple) == 1 {
522521
return sev, nil, nil
523522
}
524-
if len(tuple) > 2 {
525-
return SeverityOff, nil, fmt.Errorf("severity tuple must be [severity] or [severity, options], got %d elements", len(tuple))
526-
}
527-
if tuple[1] == nil {
523+
if len(tuple) == 2 && tuple[1] == nil {
528524
return sev, nil, nil
529525
}
530-
if _, ok := tuple[1].(map[string]any); !ok {
531-
// A positional string option (e.g. `["error", "single"]`) is
532-
// rejected: every option struct in TtscLintRuleOptions is an
533-
// object, and silently encoding a non-object slot would land in
534-
// DecodeOptions as a decode error that every rule discards. Fail
535-
// loudly so users discover the proper `["error", { … }]` form.
536-
return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
526+
var payload any = tuple[1]
527+
if len(tuple) > 2 {
528+
payload = tuple[1:]
537529
}
538-
encoded, err := json.Marshal(tuple[1])
530+
encoded, err := json.Marshal(payload)
539531
if err != nil {
540532
return SeverityOff, nil, fmt.Errorf("encode options: %w", err)
541533
}

packages/lint/linthost/engine.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,10 @@ func ruleNeedsTypeChecker(r Rule) bool {
8585

8686
// Context is the per-(file, rule) handle the engine passes to `Check`.
8787
//
88-
// `Options` is the raw JSON blob the user wrote in their rule
89-
// configuration's second tuple slot (`["warning", { ... }]`). It is nil
90-
// when the rule was configured with a bare severity literal. Rules that
91-
// accept options decode the blob into their own struct via
92-
// `(*Context).DecodeOptions` and fall back to defaults on nil.
88+
// `Options` is the raw JSON payload from the rule configuration. One option
89+
// slot preserves its scalar or object shape; multiple positional options are
90+
// an array. It is nil for a bare severity. Rules decode the payload according
91+
// to their public option type and fall back to defaults on nil.
9392
type Context struct {
9493
File *shimast.SourceFile
9594
Checker *shimchecker.Checker
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package linthost
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
7+
shimast "github.com/microsoft/typescript-go/shim/ast"
8+
shimcore "github.com/microsoft/typescript-go/shim/core"
9+
)
10+
11+
// noInnerDeclarations enforces the root-declaration positions and option
12+
// defaults of ESLint's core no-inner-declarations rule. TypeScript-Go parses
13+
// JavaScript and TypeScript with the current ECMAScript grammar, so strict
14+
// functions in ordinary parsed source have ES2015 block-function semantics.
15+
// https://eslint.org/docs/latest/rules/no-inner-declarations
16+
type noInnerDeclarations struct{}
17+
18+
type noInnerDeclarationsOptions struct {
19+
both bool
20+
allowBlockScopedFunc bool
21+
}
22+
23+
type noInnerDeclarationsBlockOptions struct {
24+
BlockScopedFunctions string `json:"blockScopedFunctions"`
25+
}
26+
27+
func (noInnerDeclarations) Name() string { return "no-inner-declarations" }
28+
29+
func (noInnerDeclarations) Visits() []shimast.Kind {
30+
return []shimast.Kind{
31+
shimast.KindFunctionDeclaration,
32+
shimast.KindVariableDeclarationList,
33+
}
34+
}
35+
36+
func (noInnerDeclarations) Check(ctx *Context, node *shimast.Node) {
37+
options := resolveNoInnerDeclarationsOptions(ctx)
38+
declaration := node
39+
declarationType := "function"
40+
41+
if node.Kind == shimast.KindVariableDeclarationList {
42+
if !options.both || !shimast.IsVar(node) {
43+
return
44+
}
45+
declarationType = "variable"
46+
// A statement-level list is wrapped by VariableStatement in the tsgo AST.
47+
// Use that wrapper for root classification and for the diagnostic range;
48+
// loop-header lists have no wrapper and remain nested declarations.
49+
if node.Parent != nil && node.Parent.Kind == shimast.KindVariableStatement {
50+
declaration = node.Parent
51+
}
52+
} else if options.allowBlockScopedFunc &&
53+
noInnerDeclarationsSupportsBlockFunctions(ctx.File) &&
54+
noInnerDeclarationsIsStrict(ctx.File, node) {
55+
return
56+
}
57+
58+
if noInnerDeclarationsIsRoot(declaration) {
59+
return
60+
}
61+
ctx.Report(
62+
declaration,
63+
"Move "+declarationType+" declaration to "+noInnerDeclarationsAllowedBody(declaration)+" root.",
64+
)
65+
}
66+
67+
// resolveNoInnerDeclarationsOptions reads the canonical ESLint positional
68+
// options. The config transport preserves one positional value directly and
69+
// two or more values as an array, so both [severity, mode] and
70+
// [severity, mode, object] arrive without rule-specific parser behavior.
71+
func resolveNoInnerDeclarationsOptions(ctx *Context) noInnerDeclarationsOptions {
72+
resolved := noInnerDeclarationsOptions{allowBlockScopedFunc: true}
73+
if ctx == nil || len(ctx.Options) == 0 {
74+
return resolved
75+
}
76+
77+
raw := bytes.TrimSpace(ctx.Options)
78+
if len(raw) == 0 {
79+
return resolved
80+
}
81+
slots := []json.RawMessage{raw}
82+
if raw[0] == '[' {
83+
if err := json.Unmarshal(raw, &slots); err != nil {
84+
return resolved
85+
}
86+
}
87+
88+
if len(slots) > 0 {
89+
var mode string
90+
if json.Unmarshal(slots[0], &mode) == nil && mode == "both" {
91+
resolved.both = true
92+
}
93+
}
94+
if len(slots) > 1 {
95+
var options noInnerDeclarationsBlockOptions
96+
if json.Unmarshal(slots[1], &options) == nil && options.BlockScopedFunctions == "disallow" {
97+
resolved.allowBlockScopedFunc = false
98+
}
99+
}
100+
return resolved
101+
}
102+
103+
func noInnerDeclarationsSupportsBlockFunctions(file *shimast.SourceFile) bool {
104+
return file != nil && file.ScriptKind != shimcore.ScriptKindJSON
105+
}
106+
107+
// noInnerDeclarationsIsStrict derives strictness from parser-owned AST facts:
108+
// external-module identity, class ancestry, and real directive prologues. It
109+
// deliberately does not infer semantics from filenames or substring searches.
110+
func noInnerDeclarationsIsStrict(file *shimast.SourceFile, node *shimast.Node) bool {
111+
if file != nil && file.ExternalModuleIndicator != nil {
112+
return true
113+
}
114+
for ancestor := node.Parent; ancestor != nil; ancestor = ancestor.Parent {
115+
switch ancestor.Kind {
116+
case shimast.KindClassDeclaration, shimast.KindClassExpression:
117+
return true
118+
case shimast.KindSourceFile, shimast.KindModuleBlock:
119+
if noInnerDeclarationsHasUseStrictDirective(file, ancestor) {
120+
return true
121+
}
122+
case shimast.KindBlock:
123+
if isFunctionLikeKind(ancestor.Parent) && noInnerDeclarationsHasUseStrictDirective(file, ancestor) {
124+
return true
125+
}
126+
}
127+
}
128+
return false
129+
}
130+
131+
func noInnerDeclarationsHasUseStrictDirective(file *shimast.SourceFile, container *shimast.Node) bool {
132+
for _, statement := range parentStatements(container) {
133+
if statement == nil || statement.Kind != shimast.KindExpressionStatement {
134+
return false
135+
}
136+
expressionStatement := statement.AsExpressionStatement()
137+
if expressionStatement == nil || expressionStatement.Expression == nil ||
138+
expressionStatement.Expression.Kind != shimast.KindStringLiteral {
139+
return false
140+
}
141+
// ES5 forbids escapes in a Use Strict Directive. Match the parser/binder
142+
// contract against the parser-classified literal token, not its cooked
143+
// value (`"use\x20strict"` must stay sloppy).
144+
raw := nodeText(file, expressionStatement.Expression)
145+
if raw == `"use strict"` || raw == `'use strict'` {
146+
return true
147+
}
148+
}
149+
return false
150+
}
151+
152+
func noInnerDeclarationsIsRoot(declaration *shimast.Node) bool {
153+
if declaration == nil || declaration.Parent == nil {
154+
return true
155+
}
156+
parent := declaration.Parent
157+
switch parent.Kind {
158+
case shimast.KindSourceFile, shimast.KindModuleBlock, shimast.KindClassStaticBlockDeclaration:
159+
return true
160+
case shimast.KindBlock:
161+
return isFunctionLikeKind(parent.Parent) ||
162+
(parent.Parent != nil && parent.Parent.Kind == shimast.KindClassStaticBlockDeclaration)
163+
}
164+
return false
165+
}
166+
167+
func noInnerDeclarationsAllowedBody(declaration *shimast.Node) string {
168+
for ancestor := declaration.Parent; ancestor != nil; ancestor = ancestor.Parent {
169+
if ancestor.Kind == shimast.KindClassStaticBlockDeclaration {
170+
return "class static block body"
171+
}
172+
if isFunctionLikeKind(ancestor) {
173+
return "function body"
174+
}
175+
}
176+
return "program"
177+
}
178+
179+
func init() {
180+
Register(noInnerDeclarations{})
181+
}

packages/lint/linthost/rules_problems.go

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -531,49 +531,6 @@ func isIrregularWhitespace(r rune) bool {
531531
return false
532532
}
533533

534-
// noInnerDeclarations: `function foo() { if (x) { function bar() {} } }`
535-
// — inner function declarations are hoisted differently in strict mode
536-
// vs sloppy and are confusing.
537-
type noInnerDeclarations struct{}
538-
539-
func (noInnerDeclarations) Name() string { return "no-inner-declarations" }
540-
func (noInnerDeclarations) Visits() []shimast.Kind {
541-
return []shimast.Kind{shimast.KindFunctionDeclaration, shimast.KindVariableStatement}
542-
}
543-
func (noInnerDeclarations) Check(ctx *Context, node *shimast.Node) {
544-
if node.Kind == shimast.KindVariableStatement {
545-
stmt := node.AsVariableStatement()
546-
if stmt == nil || stmt.DeclarationList == nil {
547-
return
548-
}
549-
// Only `var` is hoisted oddly.
550-
if !shimast.IsVar(stmt.DeclarationList) {
551-
return
552-
}
553-
}
554-
parent := node.Parent
555-
if parent == nil {
556-
return
557-
}
558-
switch parent.Kind {
559-
case shimast.KindSourceFile, shimast.KindModuleBlock:
560-
return
561-
case shimast.KindBlock:
562-
grand := parent.Parent
563-
if grand == nil {
564-
return
565-
}
566-
if isFunctionLikeKind(grand) {
567-
return
568-
}
569-
}
570-
what := "function"
571-
if node.Kind == shimast.KindVariableStatement {
572-
what = "variable"
573-
}
574-
ctx.Report(node, "Move "+what+" declaration to the function scope.")
575-
}
576-
577534
// noObjCalls: `Math()`, `JSON()` — these globals are objects, not
578535
// callables. ESLint catches a small list.
579536
type noObjCalls struct{}
@@ -633,6 +590,5 @@ func init() {
633590
Register(noAsyncPromiseExecutor{})
634591
Register(noControlRegex{})
635592
Register(noIrregularWhitespace{})
636-
Register(noInnerDeclarations{})
637593
Register(noObjCalls{})
638594
}

packages/lint/src/structures/TtscLintRuleSetting.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ export type TtscLintRuleSetting =
2727
| readonly [TtscLintSeverity];
2828

2929
/**
30-
* Per-rule severity-plus-options setting for rules that accept a typed options
31-
* object.
30+
* Per-rule severity-plus-options setting for rules that accept one typed
31+
* options object. Rules with canonical positional option lists expose a
32+
* dedicated setting type instead.
3233
*
3334
* This is the tuple form ESLint users know — `[severity, options]` — kept
3435
* strongly typed by way of the rule's dedicated options interface (see

packages/lint/src/structures/rules/ITtscLintCoreRuleOptions.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { TtscLintRuleSetting } from "../TtscLintRuleSetting";
2+
import type { TtscLintSeverity } from "../TtscLintSeverity";
3+
14
/**
25
* Options shapes for the configurable rules in {@link ITtscLintCoreRules}.
36
*
@@ -80,6 +83,33 @@ export interface ITtscLintCoreNoUnusedExpressionsRuleOptions {
8083
ignoreDirectives?: boolean;
8184
}
8285

86+
/** Object option for ESLint's canonical `no-inner-declarations` tuple. */
87+
export interface ITtscLintCoreNoInnerDeclarationsRuleOptions {
88+
/**
89+
* Allow ES2015 block-scoped function declarations in strict scripts,
90+
* modules, and class code, or report them as a style policy.
91+
*
92+
* @default "allow"
93+
*/
94+
blockScopedFunctions?: "allow" | "disallow";
95+
}
96+
97+
/**
98+
* Canonical positional setting for `no-inner-declarations`.
99+
*
100+
* The declaration mode is ESLint's first option. The optional object remains
101+
* the second option, so existing ESLint configurations can be copied without
102+
* reshaping them into a ttsc-only object.
103+
*/
104+
export type TtscLintCoreNoInnerDeclarationsRuleSetting =
105+
| TtscLintRuleSetting
106+
| readonly [TtscLintSeverity, "functions" | "both"]
107+
| readonly [
108+
TtscLintSeverity,
109+
"functions" | "both",
110+
ITtscLintCoreNoInnerDeclarationsRuleOptions,
111+
];
112+
83113
/**
84114
* `no-fallthrough` rule options.
85115
*

0 commit comments

Comments
 (0)