ivue 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,7 @@
22
22
  * skip-list vocabulary, and the constitution in one gesture:
23
23
  *
24
24
  * class $HouseGate extends CheckStandard.$Class {
25
- * static get house_rule(): StandardCheck { … }
25
+ * static get house_rule(): CheckStandard.StandardCheck { … }
26
26
  * static get checks() { return [...super.checks, this.house_rule]; }
27
27
  * static get proofs() { return { ...super.proofs, [this.house_rule.name]: … }; }
28
28
  * }
@@ -43,167 +43,6 @@ import { parse as parseSfc } from '@vue/compiler-sfc';
43
43
  import { parse as parseTemplate, NodeTypes, type ElementNode, type TemplateChildNode } from '@vue/compiler-dom';
44
44
  import { Static } from '../../lib/Static';
45
45
 
46
- // ---------------------------------------------------------------------------
47
- // public types
48
-
49
- export interface Finding {
50
- check: string;
51
- file: string;
52
- line: number;
53
- message: string;
54
- }
55
-
56
- export type StaticTransform = <Class extends new (...arguments_: any[]) => any>(targetClass: Class) => Class;
57
-
58
- export interface GateOptions {
59
- cwd: string;
60
- sourceRoots: string[];
61
- testGlobs: string[];
62
- skipListPath?: string;
63
- /** programmatic severity overrides (the declarative home is the gate
64
- * class's `severities` getter): demoted to warnings — reported, never
65
- * blocking … */
66
- warnChecks?: string[];
67
- /** … or disabled — not executed, announced in the summary */
68
- offChecks?: string[];
69
- /** the `Static` used by the runtime probe; defaults to this package's own */
70
- staticImplementation?: StaticTransform | null;
71
- }
72
-
73
- export interface GateResult {
74
- /** blocking findings — checks at severity error */
75
- findings: Finding[];
76
- /** findings from checks demoted to warn — reported, never blocking */
77
- warnings: Finding[];
78
- suppressed: Finding[];
79
- sources: string[];
80
- tests: string[];
81
- unenforced: string[];
82
- /** checks turned off for this run — announced, never silent */
83
- off: string[];
84
- }
85
-
86
- export interface StandardCheck {
87
- /** The identity: a plain declarative sentence, used verbatim everywhere. */
88
- name: string;
89
- /** false = registered in the manifest but not enforced yet; the report says so. */
90
- enforced: boolean;
91
- run(context: GateContext): Finding[];
92
- }
93
-
94
- /** One permanent proof fixture: a small checkout the gate runs over. */
95
- export interface CheckProofArm {
96
- /** repo-relative path → file text; sources under `src/` by convention */
97
- files: Record<string, string>;
98
- /** package.json for the fixture checkout (default: an ivue consumer) */
99
- manifest?: Record<string, unknown>;
100
- /** GateOptions overrides for this arm (e.g. a broken staticImplementation) */
101
- options?: Partial<GateOptions>;
102
- /** red arms: each pattern must match at least one of the check's findings */
103
- expectFindings?: (RegExp | string)[];
104
- /** red arms: exact number of findings the check must produce */
105
- expectCount?: number;
106
- /** arms with warn-demoted checks: each pattern must match a warning */
107
- expectWarnings?: (RegExp | string)[];
108
- /** red arms for population refusals: run() must throw matching this */
109
- expectThrows?: RegExp;
110
- }
111
-
112
- /** A check's constitution entry: its claim, its boundary, and both arms. */
113
- export interface CheckProof {
114
- claim: string;
115
- impossibility: string;
116
- red: CheckProofArm[];
117
- green: CheckProofArm[];
118
- }
119
-
120
- export interface ProveReport {
121
- problems: string[];
122
- ran: { red: number; green: number };
123
- }
124
-
125
- export interface SourceUnit {
126
- path: string;
127
- relativePath: string;
128
- text: string;
129
- lines: string[];
130
- ast: ts.SourceFile;
131
- }
132
-
133
- /** A `.vue` single-file component: its script setup as TS plus every template expression. */
134
- export interface ComponentUnit {
135
- path: string;
136
- relativePath: string;
137
- text: string;
138
- script: SourceUnit | null;
139
- /** 1-based line of the script block's first line in the .vue file */
140
- scriptLine: number;
141
- expressions: TemplateExpression[];
142
- }
143
-
144
- export interface TemplateExpression {
145
- code: string;
146
- line: number;
147
- kind: string;
148
- }
149
-
150
- export interface GateContext {
151
- cwd: string;
152
- /** absolute source roots, as discovered */
153
- sourceRoots: string[];
154
- sources: SourceUnit[];
155
- tests: SourceUnit[];
156
- components: ComponentUnit[];
157
- testGlobs: string[];
158
- staticImplementation: StaticTransform | null;
159
- }
160
-
161
- interface ClassFile {
162
- unit: SourceUnit;
163
- rawClass: ts.ClassDeclaration;
164
- rawName: string;
165
- publicName: string;
166
- namespace: ts.ModuleDeclaration | null;
167
- anchorInitializer: ts.Expression | null;
168
- classInitializer: ts.Expression | null;
169
- hasInstanceType: boolean;
170
- isReactive: boolean;
171
- isStaticAnchored: boolean;
172
- }
173
-
174
- interface GeneratorHeader {
175
- present: boolean;
176
- firstContent: boolean;
177
- goal: string;
178
- formal: string;
179
- described: string;
180
- orderedRegisters: boolean;
181
- bothRegisters: boolean;
182
- subjects: { path: string; line: number }[];
183
- domainClaims: Map<string, { symbol: string; claim: string; line: number }>;
184
- domainSymbols: Set<string>;
185
- impossibilities: Map<string, number>;
186
- contractLinks: { text: string; file: string; anchor: string; line: number }[];
187
- endLine: number;
188
- }
189
-
190
- interface ProofAnnotation {
191
- type: 'domain' | 'impossible' | 'record';
192
- symbol?: string;
193
- claim?: string;
194
- name?: string;
195
- contractPath?: string;
196
- line: number;
197
- bound: boolean;
198
- }
199
-
200
- interface SkipRow {
201
- path: string;
202
- check: string;
203
- reason: string;
204
- line: number;
205
- }
206
-
207
46
  // ---------------------------------------------------------------------------
208
47
  // the gate class — statics only; getters carry data, methods carry behavior
209
48
 
@@ -214,7 +53,7 @@ class $CheckStandard {
214
53
 
215
54
  static readonly BANNED_NAMES = new Set([
216
55
  'inst', 'qty', 'agg', 'nv', 'ov', 'val', 'arr', 'obj', 'fn', 'cb', 'el', 'evt', 'tmp', 'idx', 'err',
217
- 'num', 'str', 'ctx', 'res', 'msg', 'cnt', 'len', 'ret', 'prev', 'old',
56
+ 'num', 'str', 'ctx', 'res', 'msg', 'cnt', 'len', 'ret', 'prev', 'old', 'ci', 'ri',
218
57
  ]);
219
58
 
220
59
  static readonly DOMAIN_TERMS = new Set(['px', 'id', 'fx', 'x', 'y', 'z']);
@@ -254,7 +93,7 @@ class $CheckStandard {
254
93
  // -------------------------------------------------------------------------
255
94
  // the checks — the getter name is the snake_case of the sentence name
256
95
 
257
- static get exactly_one_reactive_source_is_installed(): StandardCheck {
96
+ static get exactly_one_reactive_source_is_installed(): CheckStandard.StandardCheck {
258
97
  return this.defineCheck('exactly_one_reactive_source_is_installed', (context) => {
259
98
  const vendored = context.sources.filter((unit) => /export\s+function\s+Reactive\s*[<(]/.test(unit.text) || /export\s*\{[^}]*\bReactive\b[^}]*\}\s*from/.test(unit.text));
260
99
  const manifests = new Set<string>();
@@ -288,9 +127,9 @@ class $CheckStandard {
288
127
  });
289
128
  }
290
129
 
291
- static get a_public_class_publishes_its_namespace_manifest(): StandardCheck {
130
+ static get a_public_class_publishes_its_namespace_manifest(): CheckStandard.StandardCheck {
292
131
  return this.defineCheck('a_public_class_publishes_its_namespace_manifest', (context) => {
293
- const findings: Finding[] = [];
132
+ const findings: CheckStandard.Finding[] = [];
294
133
  const unwrap = (expression: ts.Expression): ts.Expression => {
295
134
  let current = expression;
296
135
  while (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current)) current = current.expression;
@@ -336,9 +175,9 @@ class $CheckStandard {
336
175
  });
337
176
  }
338
177
 
339
- static get a_class_file_is_named_after_its_class(): StandardCheck {
178
+ static get a_class_file_is_named_after_its_class(): CheckStandard.StandardCheck {
340
179
  return this.defineCheck('a_class_file_is_named_after_its_class', (context) => {
341
- const findings: Finding[] = [];
180
+ const findings: CheckStandard.Finding[] = [];
342
181
  for (const unit of context.sources) {
343
182
  const classFile = this.classFileOf(unit);
344
183
  if (!classFile) continue;
@@ -350,9 +189,9 @@ class $CheckStandard {
350
189
  });
351
190
  }
352
191
 
353
- static get a_class_file_holds_only_imports_class_namespace_and_types(): StandardCheck {
192
+ static get a_class_file_holds_only_imports_class_namespace_and_types(): CheckStandard.StandardCheck {
354
193
  return this.defineCheck('a_class_file_holds_only_imports_class_namespace_and_types', (context) => {
355
- const findings: Finding[] = [];
194
+ const findings: CheckStandard.Finding[] = [];
356
195
  for (const unit of context.sources) {
357
196
  const classFile = this.classFileOf(unit);
358
197
  if (!classFile) continue;
@@ -374,7 +213,14 @@ class $CheckStandard {
374
213
  if (!seenClass) findings.push(this.finding(this.a_class_file_holds_only_imports_class_namespace_and_types, unit, this.lineOf(unit, statement), `namespace ${classFile.publicName} precedes its class ${classFile.rawName}`));
375
214
  continue;
376
215
  }
377
- if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isEnumDeclaration(statement)) continue;
216
+ // `interface $Box extends ReactiveHelpers {}` merges the engine's
217
+ // helpers into the class's own instance type — it is the class's
218
+ // second half, not a type outside the namespace
219
+ if (ts.isInterfaceDeclaration(statement) && statement.name.text === classFile.rawName) continue;
220
+ if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isEnumDeclaration(statement)) {
221
+ findings.push(this.finding(this.a_class_file_holds_only_imports_class_namespace_and_types, unit, this.lineOf(unit, statement), `\`${statement.name.text}\` is a type outside the namespace — every type a class file declares is a member of \`namespace ${classFile.publicName}\` (export type / interface), read as \`${classFile.publicName}.${statement.name.text}\``));
222
+ continue;
223
+ }
378
224
  if (ts.isExportDeclaration(statement) && statement.isTypeOnly) continue;
379
225
  findings.push(this.finding(this.a_class_file_holds_only_imports_class_namespace_and_types, unit, this.lineOf(unit, statement), 'behavior or data outside the class seam — move it into the class (static get / method) or its namespace'));
380
226
  }
@@ -383,9 +229,36 @@ class $CheckStandard {
383
229
  });
384
230
  }
385
231
 
386
- static get behavior_lives_on_the_prototype_not_in_fields(): StandardCheck {
232
+ static get the_namespace_holds_identity_and_types_only(): CheckStandard.StandardCheck {
233
+ return this.defineCheck('the_namespace_holds_identity_and_types_only', (context) => {
234
+ const findings: CheckStandard.Finding[] = [];
235
+ for (const unit of context.sources) {
236
+ const classFile = this.classFileOf(unit);
237
+ if (!classFile?.namespace?.body || !ts.isModuleBlock(classFile.namespace.body)) continue;
238
+ for (const statement of classFile.namespace.body.statements) {
239
+ if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isEnumDeclaration(statement)) continue;
240
+ if (ts.isExportDeclaration(statement) && statement.isTypeOnly) continue;
241
+ if (ts.isVariableStatement(statement)) {
242
+ const names = statement.declarationList.declarations.map((declaration) => (ts.isIdentifier(declaration.name) ? declaration.name.text : '?'));
243
+ const identity = names.every((name) => name === '$Class' || name === 'Class');
244
+ if (identity) continue;
245
+ findings.push(this.finding(this.the_namespace_holds_identity_and_types_only, unit, this.lineOf(unit, statement), `\`${names.join(', ')}\` is runtime data in namespace ${classFile.publicName} — a parallel world the class mechanics cannot reach (not inherited, not overridable, not swapped with Class); move it onto the class as a static getter`));
246
+ continue;
247
+ }
248
+ if (ts.isFunctionDeclaration(statement)) {
249
+ findings.push(this.finding(this.the_namespace_holds_identity_and_types_only, unit, this.lineOf(unit, statement), `\`${statement.name?.text ?? 'function'}\` is behavior in namespace ${classFile.publicName} — move it onto the class as a static method`));
250
+ continue;
251
+ }
252
+ findings.push(this.finding(this.the_namespace_holds_identity_and_types_only, unit, this.lineOf(unit, statement), `namespace ${classFile.publicName} holds a runtime statement — the namespace is \`$Class\`, \`Class\` and types only`));
253
+ }
254
+ }
255
+ return findings;
256
+ });
257
+ }
258
+
259
+ static get behavior_lives_on_the_prototype_not_in_fields(): CheckStandard.StandardCheck {
387
260
  return this.defineCheck('behavior_lives_on_the_prototype_not_in_fields', (context) => {
388
- const findings: Finding[] = [];
261
+ const findings: CheckStandard.Finding[] = [];
389
262
  for (const unit of context.sources) {
390
263
  const classFile = this.classFileOf(unit);
391
264
  if (!classFile) continue;
@@ -398,9 +271,9 @@ class $CheckStandard {
398
271
  });
399
272
  }
400
273
 
401
- static get construction_goes_through_the_namespace_class_slot(): StandardCheck {
274
+ static get construction_goes_through_the_namespace_class_slot(): CheckStandard.StandardCheck {
402
275
  return this.defineCheck('construction_goes_through_the_namespace_class_slot', (context) => {
403
- const findings: Finding[] = [];
276
+ const findings: CheckStandard.Finding[] = [];
404
277
  for (const unit of context.sources) {
405
278
  this.forEachDescendant(unit.ast, (node) => {
406
279
  if (ts.isNewExpression(node)) {
@@ -410,17 +283,22 @@ class $CheckStandard {
410
283
  if (ts.isPropertyAccessExpression(callee) && callee.name.text === '$Class')
411
284
  findings.push(this.finding(this.construction_goes_through_the_namespace_class_slot, unit, this.lineOf(unit, node), `\`new ${callee.getText(unit.ast)}()\` constructs the anchor — construct \`.Class\``));
412
285
  }
286
+ // `reactive(new X.Class())` bare is the proxy-on-the-standard-path
287
+ // mistake; `reactive(new X.Class() as X.Instance)` is the sanctioned
288
+ // interop form (a store's optional reactive() view) — the cast is
289
+ // what makes the unwrapped writes typecheck, so its presence is
290
+ // the signal that the author chose the concession knowingly.
413
291
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'reactive' && node.arguments[0] && ts.isNewExpression(node.arguments[0]))
414
- findings.push(this.finding(this.construction_goes_through_the_namespace_class_slot, unit, this.lineOf(unit, node), '`reactive(new …)` wraps an instance — instances are raw; no proxy on the standard path'));
292
+ findings.push(this.finding(this.construction_goes_through_the_namespace_class_slot, unit, this.lineOf(unit, node), '`reactive(new …)` wraps an instance without the Instance cast — instances are raw; a reactive() view is `reactive(new X.Class() as X.Instance)`'));
415
293
  });
416
294
  }
417
295
  return findings;
418
296
  });
419
297
  }
420
298
 
421
- static get the_anchor_is_static_only_when_statics_exist(): StandardCheck {
299
+ static get the_anchor_is_static_only_when_statics_exist(): CheckStandard.StandardCheck {
422
300
  return this.defineCheck('the_anchor_is_static_only_when_statics_exist', (context) => {
423
- const findings: Finding[] = [];
301
+ const findings: CheckStandard.Finding[] = [];
424
302
  for (const unit of context.sources) {
425
303
  const classFile = this.classFileOf(unit);
426
304
  if (!classFile?.namespace || !classFile.anchorInitializer) continue;
@@ -435,13 +313,13 @@ class $CheckStandard {
435
313
  });
436
314
  }
437
315
 
438
- static get static_binds_methods_and_caches_dollar_getters_per_receiver(): StandardCheck {
316
+ static get static_binds_methods_and_caches_dollar_getters_per_receiver(): CheckStandard.StandardCheck {
439
317
  return this.defineCheck('static_binds_methods_and_caches_dollar_getters_per_receiver', (context) => {
440
- const unit: SourceUnit | undefined = context.sources[0];
441
- const probe = (message: string): Finding => ({ check: 'static_binds_methods_and_caches_dollar_getters_per_receiver', file: 'ivue/extras', line: 0, message: `${message} (probed from ${unit?.relativePath ?? 'the gate'})` });
318
+ const unit: CheckStandard.SourceUnit | undefined = context.sources[0];
319
+ const probe = (message: string): CheckStandard.Finding => ({ check: 'static_binds_methods_and_caches_dollar_getters_per_receiver', file: 'ivue/extras', line: 0, message: `${message} (probed from ${unit?.relativePath ?? 'the gate'})` });
442
320
  const StaticUnderTest = context.staticImplementation;
443
321
  if (!StaticUnderTest) return [probe('`Static` could not be loaded from ivue/extras — the runtime probe did not run')];
444
- const findings: Finding[] = [];
322
+ const findings: CheckStandard.Finding[] = [];
445
323
  let cacheRuns = 0;
446
324
  class $Probe {
447
325
  static get $cache() {
@@ -466,9 +344,9 @@ class $CheckStandard {
466
344
  });
467
345
  }
468
346
 
469
- static get a_shared_store_is_a_static_readonly_field(): StandardCheck {
347
+ static get a_shared_store_is_a_static_readonly_field(): CheckStandard.StandardCheck {
470
348
  return this.defineCheck('a_shared_store_is_a_static_readonly_field', (context) => {
471
- const findings: Finding[] = [];
349
+ const findings: CheckStandard.Finding[] = [];
472
350
  for (const unit of context.sources) {
473
351
  const classFile = this.classFileOf(unit);
474
352
  if (!classFile) continue;
@@ -493,9 +371,9 @@ class $CheckStandard {
493
371
  });
494
372
  }
495
373
 
496
- static get a_derived_static_getter_is_lower_camel_case(): StandardCheck {
374
+ static get a_derived_static_getter_is_lower_camel_case(): CheckStandard.StandardCheck {
497
375
  return this.defineCheck('a_derived_static_getter_is_lower_camel_case', (context) => {
498
- const findings: Finding[] = [];
376
+ const findings: CheckStandard.Finding[] = [];
499
377
  const isLiteral = (expression: ts.Expression): boolean => {
500
378
  if (ts.isNumericLiteral(expression) || ts.isStringLiteralLike(expression) || ts.isRegularExpressionLiteral(expression) || expression.kind === ts.SyntaxKind.TrueKeyword || expression.kind === ts.SyntaxKind.FalseKeyword) return true;
501
379
  if (ts.isPrefixUnaryExpression(expression) && expression.operator === ts.SyntaxKind.MinusToken) return isLiteral(expression.operand);
@@ -504,6 +382,9 @@ class $CheckStandard {
504
382
  if (ts.isArrayLiteralExpression(expression)) return expression.elements.every((element) => ts.isExpression(element) && isLiteral(element));
505
383
  if (ts.isObjectLiteralExpression(expression)) return expression.properties.every((property) => ts.isPropertyAssignment(property) && isLiteral(property.initializer));
506
384
  if (ts.isAsExpression(expression)) return isLiteral(expression.expression);
385
+ // A reference to another SCREAMING constant — `this.X`, `Other.Class.X` — is
386
+ // a constant too: a knobs tree composed of parts' constants stays a constant.
387
+ if (ts.isPropertyAccessExpression(expression)) return /^[A-Z][A-Z0-9_]*$/.test(expression.name.text) && expression.name.text.includes('_');
507
388
  return false;
508
389
  };
509
390
  for (const unit of context.sources) {
@@ -516,16 +397,16 @@ class $CheckStandard {
516
397
  const returned = member.body.statements.find(ts.isReturnStatement)?.expression;
517
398
  if (returned && isLiteral(returned)) continue;
518
399
  const camel = name.toLowerCase().replace(/_(\w)/g, (whole, letter: string) => letter.toUpperCase());
519
- findings.push(this.finding(this.a_derived_static_getter_is_lower_camel_case, unit, this.lineOf(unit, member), `static get ${name}() derives its value — a derived getter is lowerCamel (\`${camel}\`); SCREAMING_SNAKE is for literal tunable constants`));
400
+ findings.push(this.finding(this.a_derived_static_getter_is_lower_camel_case, unit, this.lineOf(unit, member), `static get ${name}() derives its value — a derived getter is lowerCamel (\`${camel}\`); SCREAMING_SNAKE is for tunable constants: literals, or other SCREAMING constants composed`));
520
401
  }
521
402
  }
522
403
  return findings;
523
404
  });
524
405
  }
525
406
 
526
- static get static_reads_go_through_self_not_the_base_class(): StandardCheck {
407
+ static get static_reads_go_through_self_not_the_base_class(): CheckStandard.StandardCheck {
527
408
  return this.defineCheck('static_reads_go_through_self_not_the_base_class', (context) => {
528
- const findings: Finding[] = [];
409
+ const findings: CheckStandard.Finding[] = [];
529
410
  for (const unit of context.sources) {
530
411
  const classFile = this.classFileOf(unit);
531
412
  if (!classFile) continue;
@@ -543,16 +424,16 @@ class $CheckStandard {
543
424
  });
544
425
  }
545
426
 
546
- static get mutable_state_is_a_ref_returning_getter(): StandardCheck {
427
+ static get mutable_state_is_a_ref_returning_getter(): CheckStandard.StandardCheck {
547
428
  return this.defineCheck('mutable_state_is_a_ref_returning_getter', (context) => {
548
- const findings: Finding[] = [];
429
+ const findings: CheckStandard.Finding[] = [];
549
430
  for (const unit of context.sources) {
550
431
  const classFile = this.classFileOf(unit);
551
432
  // A plain namespace class (no Reactive) holds plain state — there
552
433
  // is no reactivity for a field write to trigger.
553
434
  if (!classFile?.isReactive) continue;
554
435
  for (const member of classFile.rawClass.members) {
555
- if (!ts.isPropertyDeclaration(member) || this.isStaticMember(member) || this.isReadonlyMember(member)) continue;
436
+ if (!ts.isPropertyDeclaration(member) || this.isStaticMember(member) || this.isReadonlyMember(member) || this.isDeclaredMember(member)) continue;
556
437
  const initializer = member.initializer;
557
438
  const fromFactory =
558
439
  !!initializer &&
@@ -568,9 +449,9 @@ class $CheckStandard {
568
449
  });
569
450
  }
570
451
 
571
- static get a_ref_is_read_and_written_through_value(): StandardCheck {
452
+ static get a_ref_is_read_and_written_through_value(): CheckStandard.StandardCheck {
572
453
  return this.defineCheck('a_ref_is_read_and_written_through_value', (context) => {
573
- const findings: Finding[] = [];
454
+ const findings: CheckStandard.Finding[] = [];
574
455
  for (const unit of context.sources) {
575
456
  const classFile = this.classFileOf(unit);
576
457
  if (!classFile) continue;
@@ -587,9 +468,9 @@ class $CheckStandard {
587
468
  });
588
469
  }
589
470
 
590
- static get a_derivation_is_a_plain_getter_unless_computed_is_justified(): StandardCheck {
471
+ static get a_derivation_is_a_plain_getter_unless_computed_is_justified(): CheckStandard.StandardCheck {
591
472
  return this.defineCheck('a_derivation_is_a_plain_getter_unless_computed_is_justified', (context) => {
592
- const findings: Finding[] = [];
473
+ const findings: CheckStandard.Finding[] = [];
593
474
  for (const unit of context.sources) {
594
475
  const classFile = this.classFileOf(unit);
595
476
  if (!classFile) continue;
@@ -607,15 +488,15 @@ class $CheckStandard {
607
488
  });
608
489
  }
609
490
 
610
- static get a_composable_is_injected_by_a_one_call_dollar_getter(): StandardCheck {
491
+ static get a_composable_is_injected_by_a_one_call_dollar_getter(): CheckStandard.StandardCheck {
611
492
  return this.defineCheck('a_composable_is_injected_by_a_one_call_dollar_getter', (context) => {
612
- const findings: Finding[] = [];
493
+ const findings: CheckStandard.Finding[] = [];
613
494
  for (const unit of context.sources) {
614
495
  const classFile = this.classFileOf(unit);
615
496
  if (!classFile) continue;
616
497
  for (const member of classFile.rawClass.members) {
617
498
  if (ts.isPropertyDeclaration(member) && member.initializer && ts.isCallExpression(member.initializer) && ts.isIdentifier(member.initializer.expression) && /^use[A-Z]/.test(member.initializer.expression.text))
618
- findings.push(this.finding(this.a_composable_is_injected_by_a_one_call_dollar_getter, unit, this.lineOf(unit, member), `\`${this.memberName(member)} = ${member.initializer.expression.text}()\` runs at construction — inject it as \`private get $${this.memberName(member)}() { return ${member.initializer.expression.text}() }\``));
499
+ findings.push(this.finding(this.a_composable_is_injected_by_a_one_call_dollar_getter, unit, this.lineOf(unit, member), `\`${this.memberName(member)} = ${member.initializer.expression.text}()\` runs at construction — inject it as \`protected get $${this.memberName(member)}() { return ${member.initializer.expression.text}() }\``));
619
500
  if (ts.isGetAccessorDeclaration(member) && this.memberName(member).startsWith('$') && member.body) {
620
501
  const statements = member.body.statements;
621
502
  const single = statements.length === 1 && ts.isReturnStatement(statements[0]) && !!statements[0].expression && (ts.isCallExpression(statements[0].expression) || ts.isNewExpression(statements[0].expression) || ts.isPropertyAccessExpression(statements[0].expression));
@@ -628,11 +509,11 @@ class $CheckStandard {
628
509
  });
629
510
  }
630
511
 
631
- static get instance_types_only_unwrapping_surfaces(): StandardCheck {
512
+ static get instance_types_only_unwrapping_surfaces(): CheckStandard.StandardCheck {
632
513
  return this.defineCheck('instance_types_only_unwrapping_surfaces', (context) => {
633
- const findings: Finding[] = [];
514
+ const findings: CheckStandard.Finding[] = [];
634
515
  const RAW_CONTAINERS = new Set(['Array', 'ReadonlyArray', 'Map', 'Set', 'WeakMap', 'ref', 'shallowRef', 'Ref', 'ShallowRef']);
635
- const inspect = (unit: SourceUnit, report: (line: number, message: string) => void) => {
516
+ const inspect = (unit: CheckStandard.SourceUnit, report: (line: number, message: string) => void) => {
636
517
  this.forEachDescendant(unit.ast, (node) => {
637
518
  if (ts.isTypeReferenceNode(node)) {
638
519
  const tail = this.qualifiedTail(node);
@@ -659,9 +540,9 @@ class $CheckStandard {
659
540
  });
660
541
  }
661
542
 
662
- static get a_component_has_one_model_owner(): StandardCheck {
543
+ static get a_component_has_one_model_owner(): CheckStandard.StandardCheck {
663
544
  return this.defineCheck('a_component_has_one_model_owner', (context) => {
664
- const findings: Finding[] = [];
545
+ const findings: CheckStandard.Finding[] = [];
665
546
  for (const component of context.components) {
666
547
  if (!component.script) continue;
667
548
  const constructions = this.modelConstructions(component);
@@ -671,9 +552,9 @@ class $CheckStandard {
671
552
  });
672
553
  }
673
554
 
674
- static get script_setup_is_wiring_only(): StandardCheck {
555
+ static get script_setup_is_wiring_only(): CheckStandard.StandardCheck {
675
556
  return this.defineCheck('script_setup_is_wiring_only', (context) => {
676
- const findings: Finding[] = [];
557
+ const findings: CheckStandard.Finding[] = [];
677
558
  for (const component of context.components) {
678
559
  if (!component.script) continue;
679
560
  for (const statement of component.script.ast.statements) {
@@ -690,9 +571,9 @@ class $CheckStandard {
690
571
  });
691
572
  }
692
573
 
693
- static get a_lifecycle_hook_delegates_to_one_method(): StandardCheck {
574
+ static get a_lifecycle_hook_delegates_to_one_method(): CheckStandard.StandardCheck {
694
575
  return this.defineCheck('a_lifecycle_hook_delegates_to_one_method', (context) => {
695
- const findings: Finding[] = [];
576
+ const findings: CheckStandard.Finding[] = [];
696
577
  for (const component of context.components) {
697
578
  if (!component.script) continue;
698
579
  this.forEachDescendant(component.script.ast, (node) => {
@@ -709,9 +590,9 @@ class $CheckStandard {
709
590
  });
710
591
  }
711
592
 
712
- static get the_state_destructure_is_total(): StandardCheck {
593
+ static get the_state_destructure_is_total(): CheckStandard.StandardCheck {
713
594
  return this.defineCheck('the_state_destructure_is_total', (context) => {
714
- const findings: Finding[] = [];
595
+ const findings: CheckStandard.Finding[] = [];
715
596
  for (const component of context.components) {
716
597
  if (!component.script) continue;
717
598
  const props = this.propNames(component);
@@ -755,9 +636,43 @@ class $CheckStandard {
755
636
  });
756
637
  }
757
638
 
758
- static get template_expressions_carry_no_logic(): StandardCheck {
639
+ static get one_handler_per_event(): CheckStandard.StandardCheck {
640
+ return this.defineCheck('one_handler_per_event', (context) => {
641
+ const findings: CheckStandard.Finding[] = [];
642
+ const visit = (nodes: TemplateChildNode[], component: CheckStandard.ComponentUnit, offset: number) => {
643
+ for (const node of nodes) {
644
+ if (node.type !== NodeTypes.ELEMENT) continue;
645
+ const element = node as ElementNode;
646
+ const handlers = new Map<string, string[]>();
647
+ for (const property of element.props) {
648
+ if (property.type !== NodeTypes.DIRECTIVE || property.name !== 'on') continue;
649
+ if (!property.arg || property.arg.type !== NodeTypes.SIMPLE_EXPRESSION || !property.arg.isStatic) continue;
650
+ if (!property.exp || property.exp.type !== NodeTypes.SIMPLE_EXPRESSION) continue;
651
+ const handler = property.exp.content.trim();
652
+ const events = handlers.get(handler) ?? [];
653
+ events.push(property.arg.content);
654
+ handlers.set(handler, events);
655
+ }
656
+ for (const [handler, events] of handlers) {
657
+ if (events.length < 2) continue;
658
+ findings.push(this.componentFinding(this.one_handler_per_event, component, element.loc.start.line + offset, `\`@${events.join('` and `@')}\` bind one handler (\`${handler}\`) — one DOM event, one handler named for the event: give each its own method, a one-line delegate where they coincide, so a subclass can extend either alone`));
659
+ }
660
+ visit(element.children, component, offset);
661
+ }
662
+ };
663
+ for (const component of context.components) {
664
+ const { descriptor } = parseSfc(component.text, { filename: component.path });
665
+ if (!descriptor.template) continue;
666
+ const templateAst = parseTemplate(descriptor.template.content, { comments: false });
667
+ visit(templateAst.children, component, descriptor.template.loc.start.line - 1);
668
+ }
669
+ return findings;
670
+ });
671
+ }
672
+
673
+ static get template_expressions_carry_no_logic(): CheckStandard.StandardCheck {
759
674
  return this.defineCheck('template_expressions_carry_no_logic', (context) => {
760
- const findings: Finding[] = [];
675
+ const findings: CheckStandard.Finding[] = [];
761
676
  const isNamedRead = (expression: ts.Expression): boolean => {
762
677
  if (ts.isIdentifier(expression) || expression.kind === ts.SyntaxKind.ThisKeyword) return true;
763
678
  if (ts.isPropertyAccessExpression(expression)) return isNamedRead(expression.expression);
@@ -799,20 +714,41 @@ class $CheckStandard {
799
714
  });
800
715
  }
801
716
 
802
- static get watch_lifetime_matches_the_instance_owner(): StandardCheck {
717
+ static get watch_lifetime_matches_the_instance_owner(): CheckStandard.StandardCheck {
803
718
  return this.defineCheck('watch_lifetime_matches_the_instance_owner', (context) => {
804
- const findings: Finding[] = [];
719
+ const findings: CheckStandard.Finding[] = [];
805
720
  const componentScoped = new Set<string>();
806
721
  for (const component of context.components) for (const construction of this.modelConstructions(component)) componentScoped.add(construction.namespace);
807
- const outliving = new Set<string>();
722
+ // A class constructed INSIDE a component-scoped class (its constructor
723
+ // or a `$`-getter touched there) shares that component's lifetime —
724
+ // the host is the seam; close over hosts to a fixpoint.
725
+ const constructedInside = new Map<string, Set<string>>();
726
+ const constructedElsewhere = new Set<string>();
808
727
  for (const unit of context.sources) {
728
+ const classFile = this.classFileOf(unit);
809
729
  this.forEachDescendant(unit.ast, (node) => {
810
730
  if (!ts.isNewExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== 'Class' || !ts.isIdentifier(node.expression.expression)) return;
811
- outliving.add(node.expression.expression.text);
731
+ const constructed = node.expression.expression.text;
732
+ let ancestor: ts.Node | undefined = node.parent;
733
+ while (ancestor && ancestor !== classFile?.rawClass) ancestor = ancestor.parent;
734
+ if (classFile && ancestor === classFile.rawClass) {
735
+ if (!constructedInside.has(classFile.publicName)) constructedInside.set(classFile.publicName, new Set());
736
+ constructedInside.get(classFile.publicName)!.add(constructed);
737
+ } else constructedElsewhere.add(constructed);
812
738
  });
813
- const classFile = this.classFileOf(unit);
814
- if (classFile?.namespace?.body && ts.isModuleBlock(classFile.namespace.body) && classFile.namespace.body.statements.some((statement) => ts.isFunctionDeclaration(statement) && statement.name?.text === 'use')) outliving.add(classFile.publicName);
739
+ if (classFile?.namespace?.body && ts.isModuleBlock(classFile.namespace.body) && classFile.namespace.body.statements.some((statement) => ts.isFunctionDeclaration(statement) && statement.name?.text === 'use')) constructedElsewhere.add(classFile.publicName);
740
+ if (classFile && classFile.rawClass.members.some((member) => ts.isMethodDeclaration(member) && this.isStaticMember(member) && this.memberName(member) === 'use')) constructedElsewhere.add(classFile.publicName);
815
741
  }
742
+ let grew = true;
743
+ while (grew) {
744
+ grew = false;
745
+ for (const [host, constructed] of constructedInside) {
746
+ if (!componentScoped.has(host)) continue;
747
+ for (const name of constructed) if (!componentScoped.has(name)) { componentScoped.add(name); grew = true; }
748
+ }
749
+ }
750
+ const outliving = new Set<string>(constructedElsewhere);
751
+ for (const [host, constructed] of constructedInside) if (!componentScoped.has(host)) for (const name of constructed) outliving.add(name);
816
752
  for (const unit of context.sources) {
817
753
  const classFile = this.classFileOf(unit);
818
754
  if (!classFile) continue;
@@ -820,6 +756,7 @@ class $CheckStandard {
820
756
  let usesDollarWatch = false;
821
757
  let usesPlainWatch = false;
822
758
  let hasDisposePath = false;
759
+ let hasScopeBridge = false;
823
760
  let dollarLine = 0;
824
761
  let plainLine = 0;
825
762
  this.forEachDescendant(classFile.rawClass, (node) => {
@@ -837,12 +774,18 @@ class $CheckStandard {
837
774
  usesPlainWatch = true;
838
775
  plainLine ||= this.lineOf(unit, node);
839
776
  }
840
- if (callee.text === 'onScopeDispose') hasDisposePath = true;
777
+ if (callee.text === 'onScopeDispose') {
778
+ hasDisposePath = true;
779
+ hasScopeBridge = true;
780
+ }
841
781
  }
842
782
  });
843
783
  const isComponentScoped = componentScoped.has(name) && !outliving.has(name);
844
784
  const isOutliving = outliving.has(name);
845
- if (isComponentScoped && usesDollarWatch) findings.push(this.finding(this.watch_lifetime_matches_the_instance_owner, unit, dollarLine, `${classFile.rawName} is constructed in a component's setup but uses \`this.$watch\` its scope would outlive unmount; use plain \`watch\` (the component scope reaps it)`));
785
+ // the guide's bridge`getCurrentScope() && onScopeDispose(() =>
786
+ // this.dispose())` — is the sanctioned way for an outliving-shaped
787
+ // class to be constructed inside a component as well
788
+ if (isComponentScoped && usesDollarWatch && !hasScopeBridge) findings.push(this.finding(this.watch_lifetime_matches_the_instance_owner, unit, dollarLine, `${classFile.rawName} is constructed in a component's setup but uses \`this.$watch\` — its scope would outlive unmount; use plain \`watch\` (the component scope reaps it)`));
846
789
  if (isOutliving && usesPlainWatch) findings.push(this.finding(this.watch_lifetime_matches_the_instance_owner, unit, plainLine, `${classFile.rawName} outlives components (constructed outside setup) but uses plain \`watch\` — there is no component scope to reap it; use \`this.$watch\``));
847
790
  if (usesDollarWatch && !hasDisposePath) findings.push(this.finding(this.watch_lifetime_matches_the_instance_owner, unit, dollarLine, `${classFile.rawName} registers \`$watch\` effects but has no dispose path — call \`$stopEffects()\` from an owner method, or auto-wire \`onScopeDispose\``));
848
791
  }
@@ -850,9 +793,9 @@ class $CheckStandard {
850
793
  });
851
794
  }
852
795
 
853
- static get a_reactive_closure_delegates_to_one_method(): StandardCheck {
796
+ static get a_reactive_closure_delegates_to_one_method(): CheckStandard.StandardCheck {
854
797
  return this.defineCheck('a_reactive_closure_delegates_to_one_method', (context) => {
855
- const findings: Finding[] = [];
798
+ const findings: CheckStandard.Finding[] = [];
856
799
  const reactiveCallees = new Set(['computed', 'watch', 'watchEffect', '$watch', '$watchEffect']);
857
800
  for (const unit of context.sources) {
858
801
  const classFile = this.classFileOf(unit);
@@ -884,9 +827,9 @@ class $CheckStandard {
884
827
  });
885
828
  }
886
829
 
887
- static get a_store_is_used_lazily_and_swapped_at_the_class_slot(): StandardCheck {
830
+ static get a_store_is_used_lazily_and_swapped_at_the_class_slot(): CheckStandard.StandardCheck {
888
831
  return this.defineCheck('a_store_is_used_lazily_and_swapped_at_the_class_slot', (context) => {
889
- const findings: Finding[] = [];
832
+ const findings: CheckStandard.Finding[] = [];
890
833
  for (const unit of context.sources) {
891
834
  this.forEachDescendant(unit.ast, (node) => {
892
835
  if (ts.isNewExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'Class' && !this.isInsideFunctionBody(node))
@@ -894,7 +837,7 @@ class $CheckStandard {
894
837
  if (ts.isParameter(node) && node.type && ts.isConstructorDeclaration(node.parent)) {
895
838
  const tail = this.qualifiedTail(node.type);
896
839
  if (tail && (tail.member === 'Instance' || tail.member === 'Model') && ts.isIdentifier(node.name) && /^(app|store|session|root|shell)$/i.test(node.name.text))
897
- findings.push(this.finding(this.a_store_is_used_lazily_and_swapped_at_the_class_slot, unit, this.lineOf(unit, node), `constructor takes the shared model \`${node.name.text}: ${tail.namespace}.${tail.member}\` — reach for it with \`private get $${node.name.text}() { return ${tail.namespace}.use() }\``));
840
+ findings.push(this.finding(this.a_store_is_used_lazily_and_swapped_at_the_class_slot, unit, this.lineOf(unit, node), `constructor takes the shared model \`${node.name.text}: ${tail.namespace}.${tail.member}\` — reach for it with \`protected get $${node.name.text}() { return ${tail.namespace}.use() }\``));
898
841
  }
899
842
  });
900
843
  }
@@ -917,9 +860,9 @@ class $CheckStandard {
917
860
  });
918
861
  }
919
862
 
920
- static get keyed_state_creates_on_read_and_peeks_on_write(): StandardCheck {
863
+ static get keyed_state_creates_on_read_and_peeks_on_write(): CheckStandard.StandardCheck {
921
864
  return this.defineCheck('keyed_state_creates_on_read_and_peeks_on_write', (context) => {
922
- const findings: Finding[] = [];
865
+ const findings: CheckStandard.Finding[] = [];
923
866
  const REF_TYPES = /\b(?:Ref|ShallowRef|ComputedRef|WritableComputedRef)\s*</;
924
867
  for (const unit of context.sources) {
925
868
  const classFile = this.classFileOf(unit);
@@ -945,9 +888,9 @@ class $CheckStandard {
945
888
  });
946
889
  }
947
890
 
948
- static get a_generic_reactive_class_casts_its_constructor(): StandardCheck {
891
+ static get a_generic_reactive_class_casts_its_constructor(): CheckStandard.StandardCheck {
949
892
  return this.defineCheck('a_generic_reactive_class_casts_its_constructor', (context) => {
950
- const findings: Finding[] = [];
893
+ const findings: CheckStandard.Finding[] = [];
951
894
  for (const unit of context.sources) {
952
895
  const classFile = this.classFileOf(unit);
953
896
  if (!classFile?.namespace?.body || !classFile.rawClass.typeParameters?.length || !classFile.isReactive) continue;
@@ -962,9 +905,9 @@ class $CheckStandard {
962
905
  });
963
906
  }
964
907
 
965
- static get cross_module_class_reads_happen_inside_bodies(): StandardCheck {
908
+ static get cross_module_class_reads_happen_inside_bodies(): CheckStandard.StandardCheck {
966
909
  return this.defineCheck('cross_module_class_reads_happen_inside_bodies', (context) => {
967
- const findings: Finding[] = [];
910
+ const findings: CheckStandard.Finding[] = [];
968
911
  for (const unit of context.sources) {
969
912
  const imported = this.importedBindings(unit);
970
913
  if (!imported.size) continue;
@@ -982,7 +925,13 @@ class $CheckStandard {
982
925
  if (!ts.isPropertyAccessExpression(node) || !ts.isIdentifier(node.expression)) return;
983
926
  if (!imported.has(node.expression.text)) return;
984
927
  if (node.name.text !== 'Class' && node.name.text !== '$Class') return;
985
- if (node.parent && ts.isExpressionWithTypeArguments(node.parent) && node.name.text === '$Class') return;
928
+ if (node.name.text === '$Class') {
929
+ // `extends X.$Class` — also through a cast, `extends (X.$Class as typeof X.$Class)<T>`,
930
+ // which a generic subclass needs to keep its type parameter
931
+ let heritage: ts.Node = node.parent;
932
+ while (heritage && (ts.isAsExpression(heritage) || ts.isParenthesizedExpression(heritage))) heritage = heritage.parent;
933
+ if (heritage && ts.isExpressionWithTypeArguments(heritage)) return;
934
+ }
986
935
  if (this.isInsideFunctionBody(node)) return;
987
936
  findings.push(this.finding(this.cross_module_class_reads_happen_inside_bodies, unit, this.lineOf(unit, node), `\`${node.getText(unit.ast)}\` is read at module evaluation — read it inside a getter or method body (any load order then resolves)`));
988
937
  });
@@ -991,10 +940,10 @@ class $CheckStandard {
991
940
  });
992
941
  }
993
942
 
994
- static get declarations_use_full_descriptive_names(): StandardCheck {
943
+ static get declarations_use_full_descriptive_names(): CheckStandard.StandardCheck {
995
944
  return this.defineCheck('declarations_use_full_descriptive_names', (context) => {
996
- const findings: Finding[] = [];
997
- const inspect = (unit: SourceUnit) => {
945
+ const findings: CheckStandard.Finding[] = [];
946
+ const inspect = (unit: CheckStandard.SourceUnit) => {
998
947
  this.forEachDescendant(unit.ast, (node) => {
999
948
  let identifier: ts.Identifier | null = null;
1000
949
  if ((ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node)) && ts.isIdentifier(node.name)) identifier = node.name;
@@ -1010,16 +959,29 @@ class $CheckStandard {
1010
959
  };
1011
960
  for (const unit of context.sources) inspect(unit);
1012
961
  for (const unit of context.tests) inspect(unit);
962
+ // a `v-for` alias is a declaration the template makes — same rule
963
+ for (const component of context.components) {
964
+ for (const alias of component.forAliases) {
965
+ for (const name of alias.names) {
966
+ const bare = name.replace(/^[$_]+/, '');
967
+ if (name === '_' || (bare.length === 1 && !this.DOMAIN_TERMS.has(bare)) || this.BANNED_NAMES.has(bare.toLowerCase()))
968
+ findings.push(this.componentFinding(this.declarations_use_full_descriptive_names, component, alias.line, `\`${name}\` as a \`v-for\` alias — unfold to the domain word (row, cell, column, index…); single letters and abbreviations are not names`));
969
+ }
970
+ }
971
+ }
1013
972
  return findings;
1014
973
  });
1015
974
  }
1016
975
 
1017
- static get class_members_are_ordered_and_spaced(): StandardCheck {
976
+ static get class_members_are_ordered_and_spaced(): CheckStandard.StandardCheck {
1018
977
  return this.defineCheck('class_members_are_ordered_and_spaced', (context) => {
1019
- const findings: Finding[] = [];
978
+ const findings: CheckStandard.Finding[] = [];
1020
979
  const rank = (member: ts.ClassElement): number => {
1021
980
  if (this.isStaticMember(member)) return 0;
1022
981
  if (ts.isConstructorDeclaration(member)) return 1;
982
+ // a `declare` member is a type statement (the engine installs the
983
+ // value at Reactive()); it reads first, with the statics
984
+ if (ts.isPropertyDeclaration(member) && this.isDeclaredMember(member)) return 0;
1023
985
  if (ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) return 2;
1024
986
  return 3;
1025
987
  };
@@ -1057,7 +1019,7 @@ class $CheckStandard {
1057
1019
 
1058
1020
 
1059
1021
 
1060
- static get the_population_and_skip_list_are_exact(): StandardCheck {
1022
+ static get the_population_and_skip_list_are_exact(): CheckStandard.StandardCheck {
1061
1023
  // enforced by run() itself; its findings and refusals carry this name
1062
1024
  return this.defineCheck('the_population_and_skip_list_are_exact', () => []);
1063
1025
  }
@@ -1065,12 +1027,13 @@ class $CheckStandard {
1065
1027
 
1066
1028
  /** The manifest, in the Standard's order — reads through `this`, so a
1067
1029
  * subclass's overridden or added check getters flow into it. */
1068
- static get checks(): readonly StandardCheck[] {
1030
+ static get checks(): readonly CheckStandard.StandardCheck[] {
1069
1031
  return [
1070
1032
  this.exactly_one_reactive_source_is_installed,
1071
1033
  this.a_public_class_publishes_its_namespace_manifest,
1072
1034
  this.a_class_file_is_named_after_its_class,
1073
1035
  this.a_class_file_holds_only_imports_class_namespace_and_types,
1036
+ this.the_namespace_holds_identity_and_types_only,
1074
1037
  this.behavior_lives_on_the_prototype_not_in_fields,
1075
1038
  this.construction_goes_through_the_namespace_class_slot,
1076
1039
  this.the_anchor_is_static_only_when_statics_exist,
@@ -1088,6 +1051,7 @@ class $CheckStandard {
1088
1051
  this.a_lifecycle_hook_delegates_to_one_method,
1089
1052
  this.the_state_destructure_is_total,
1090
1053
  this.template_expressions_carry_no_logic,
1054
+ this.one_handler_per_event,
1091
1055
  this.watch_lifetime_matches_the_instance_owner,
1092
1056
  this.a_reactive_closure_delegates_to_one_method,
1093
1057
  this.a_store_is_used_lazily_and_swapped_at_the_class_slot,
@@ -1238,7 +1202,7 @@ export namespace Tooltip {
1238
1202
  import { Reactive } from 'ivue';
1239
1203
 
1240
1204
  class $Sheet {
1241
- private readonly cellVersions = new Map<number, Ref<number>>();
1205
+ protected readonly cellVersions = new Map<number, Ref<number>>();
1242
1206
 
1243
1207
  trackCell(cellKey: number): void {
1244
1208
  let versionRef = this.cellVersions.get(cellKey);
@@ -1291,7 +1255,7 @@ export namespace Scroller {
1291
1255
 
1292
1256
  /** The constitution: every check's claim, impossibility, and both
1293
1257
  * permanent proof arms — per receiver, extended alongside `checks`. */
1294
- static get proofs(): Readonly<Record<string, CheckProof>> {
1258
+ static get proofs(): Readonly<Record<string, CheckStandard.CheckProof>> {
1295
1259
  const fixture = this.$fixtures;
1296
1260
  const grammar = this.$grammar;
1297
1261
  const contractName = `demo${grammar.CONTRACT_SUFFIX}`;
@@ -1340,10 +1304,16 @@ export namespace Scroller {
1340
1304
  green: [{ files: { ...box, 'src/Widget.ts': "class $WidgetPart {\n spin() {\n return 1;\n }\n}\n\nclass $Widget {\n get part() {\n return new $WidgetPart();\n }\n}\n\nexport namespace Widget {\n export const $Class = $Widget;\n export let Class = $Class;\n}\n" } }],
1341
1305
  },
1342
1306
  'a_class_file_holds_only_imports_class_namespace_and_types': {
1343
- claim: 'If a file is a class file, then its top level is imports, the class, its namespace, and type declarations, nothing else',
1307
+ claim: 'If a file is a class file, then its top level is imports, the class, and its namespace, nothing else — every type it declares is a namespace member',
1344
1308
  impossibility: 'a file breaking a_class_file_holds_only_imports_class_namespace_and_types passes the gate',
1345
- red: [{ files: { 'src/Box.ts': `${fixture.validClass}\nconst DEFAULT_WIDTH = 4;\nexport function widen(box: Box.Instance) { return box.area; }\n` }, expectFindings: [/outside the class seam/], expectCount: 2 }],
1346
- green: [{ files: { 'src/Box.ts': `${fixture.validClass}\nexport type BoxSeed = { width: number };\nexport interface BoxEmits { (event: 'grown'): void }\n` } }],
1309
+ red: [{ files: { 'src/Box.ts': `${fixture.validClass}\nconst DEFAULT_WIDTH = 4;\nexport function widen(box: Box.Instance) { return box.area; }\nexport type BoxSeed = { width: number };\n` }, expectFindings: [/outside the class seam/, /`BoxSeed` is a type outside the namespace/], expectCount: 3 }],
1310
+ green: [{ files: { 'src/Box.ts': fixture.validClass.replace(' export type Instance = typeof Class.Instance;\n', ' export type Instance = typeof Class.Instance;\n export type Seed = { width: number };\n export interface Emits {\n (event: \'grown\'): void;\n }\n') } }, { files: { 'src/Box.ts': fixture.validClass.replace("import { Reactive } from 'ivue';", "import { Reactive, type ReactiveHelpers } from 'ivue';").replace(' watch(\n () => this.height.value,', ' getCurrentScope() && onScopeDispose(() => this.$stopEffects());\n this.$watch(\n () => this.height.value,').replace("import { ref, watch } from 'vue';", "import { getCurrentScope, onScopeDispose, ref, watch } from 'vue';").replace('\nexport namespace Box {', '\ninterface $Box extends ReactiveHelpers {}\n\nexport namespace Box {') } }],
1311
+ },
1312
+ 'the_namespace_holds_identity_and_types_only': {
1313
+ claim: 'If a class file has a namespace, then the namespace holds $Class, Class, and type declarations, never runtime data or behavior (which live on the class as statics)',
1314
+ impossibility: 'a file breaking the_namespace_holds_identity_and_types_only passes the gate',
1315
+ red: [{ files: { 'src/Box.ts': fixture.validClass.replace(' export type Instance = typeof Class.Instance;\n', ' export type Instance = typeof Class.Instance;\n export const DEFAULT_WIDTH = 4;\n const SEEDS = [1, 2];\n export function widen(box: Box.Instance) {\n return box.area + SEEDS.length;\n }\n') }, expectFindings: [/`DEFAULT_WIDTH` is runtime data in namespace Box/, /`SEEDS` is runtime data/, /`widen` is behavior in namespace Box/], expectCount: 3 }],
1316
+ green: [{ files: { 'src/Box.ts': fixture.validClass.replace(' export type Instance = typeof Class.Instance;\n', ' export type Instance = typeof Class.Instance;\n export type Model = InstanceType<typeof Class>;\n export interface Seed {\n width: number;\n }\n') } }],
1347
1317
  },
1348
1318
  'behavior_lives_on_the_prototype_not_in_fields': {
1349
1319
  claim: 'If a class member is a function, then it is a method, never a function-valued field',
@@ -1352,7 +1322,7 @@ export namespace Scroller {
1352
1322
  green: [{ files: box }],
1353
1323
  },
1354
1324
  'construction_goes_through_the_namespace_class_slot': {
1355
- claim: 'If an instance is created, then it is new X.Class, never new dollar-X, new X.dollar-Class, or reactive-wrapped construction',
1325
+ claim: 'If an instance is created, then it is new X.Class, never new dollar-X, new X.dollar-Class, or bare reactive-wrapped construction; reactive(new X.Class() as X.Instance) is the sanctioned view',
1356
1326
  impossibility: 'a file breaking construction_goes_through_the_namespace_class_slot passes the gate',
1357
1327
  red: [{
1358
1328
  files: {
@@ -1366,6 +1336,12 @@ export namespace Scroller {
1366
1336
  ...box,
1367
1337
  'src/BoxFactory.ts': "import { Box } from './Box';\n\nclass $BoxFactory {\n make() {\n return new Box.Class({ width: 1 });\n }\n}\n\nexport namespace BoxFactory {\n export const $Class = $BoxFactory;\n export let Class = $Class;\n}\n",
1368
1338
  },
1339
+ }, {
1340
+ // the sanctioned reactive() view: the Instance cast marks the concession
1341
+ files: {
1342
+ ...box,
1343
+ 'src/BoxView.ts': "import { reactive } from 'vue';\nimport { Box } from './Box';\n\nclass $BoxView {\n make() {\n return reactive(new Box.Class({ width: 1 }) as Box.Instance);\n }\n}\n\nexport namespace BoxView {\n export const $Class = $BoxView;\n export let Class = $Class;\n}\n",
1344
+ },
1369
1345
  }],
1370
1346
  },
1371
1347
  'the_anchor_is_static_only_when_statics_exist': {
@@ -1384,7 +1360,7 @@ export namespace Scroller {
1384
1360
  claim: "If the consumer's Static transforms a class, then its static methods are bound with stable identity and its dollar getters run once per receiver class",
1385
1361
  impossibility: 'a file breaking static_binds_methods_and_caches_dollar_getters_per_receiver passes the gate',
1386
1362
  red: [
1387
- { files: box, options: { staticImplementation: (<Class,>(targetClass: Class) => targetClass) as StaticTransform }, expectFindings: [/does not bind static methods/, /does not cache a dollar getter once per receiver/] },
1363
+ { files: box, options: { staticImplementation: (<Class,>(targetClass: Class) => targetClass) as CheckStandard.StaticTransform }, expectFindings: [/does not bind static methods/, /does not cache a dollar getter once per receiver/] },
1388
1364
  { files: box, options: { staticImplementation: null }, expectFindings: [/could not be loaded/] },
1389
1365
  ],
1390
1366
  green: [{ files: box }],
@@ -1407,10 +1383,10 @@ export namespace Scroller {
1407
1383
  }],
1408
1384
  },
1409
1385
  'a_derived_static_getter_is_lower_camel_case': {
1410
- claim: 'If a static getter derives its value from other members or classes, then its name is lowerCamel, and SCREAMING_SNAKE remains for literal tunable constants',
1386
+ claim: 'If a static getter derives its value from other members or classes, then its name is lowerCamel, and SCREAMING_SNAKE remains for tunable constants: literals, or other SCREAMING constants composed',
1411
1387
  impossibility: 'a file breaking a_derived_static_getter_is_lower_camel_case passes the gate',
1412
1388
  red: [{ files: { 'src/Clock.ts': fixture.staticClass.replace(' static now() {', ' static get SCAN_LIMIT_HOURS() {\n return Number(this.$zone.length) * 24;\n }\n\n static now() {') }, expectFindings: [/derives its value — a derived getter is lowerCamel \(`scanLimitHours`\)/] }],
1413
- green: [{ files: { 'src/Clock.ts': fixture.staticClass.replace(' static now() {', ' static get RETRY_LIMIT() {\n return 3;\n }\n\n static get EMAIL_PATTERN() {\n return /a+b/;\n }\n\n static get scanLimitHours() {\n return Number(this.$zone.length) * 24;\n }\n\n static now() {') } }],
1389
+ green: [{ files: { 'src/Clock.ts': fixture.staticClass.replace(' static now() {', ' static get RETRY_LIMIT() {\n return 3;\n }\n\n static get EMAIL_PATTERN() {\n return /a+b/;\n }\n\n static get KNOBS() {\n return { retries: this.RETRY_LIMIT, pattern: Clock.Class.EMAIL_PATTERN };\n }\n\n static get scanLimitHours() {\n return Number(this.$zone.length) * 24;\n }\n\n static now() {') } }],
1414
1390
  },
1415
1391
  'static_reads_go_through_self_not_the_base_class': {
1416
1392
  claim: 'If instance code reads its own statics, then it reads this.self, never the base class name or a per-site constructor cast',
@@ -1424,7 +1400,7 @@ export namespace Scroller {
1424
1400
  red: [{ files: { 'src/Box.ts': fixture.validClass.replace(' get height() {', ' count = 0;\n\n get height() {') }, expectFindings: [/`count` is a mutable plain field/] }],
1425
1401
  // Db.ts is a PLAIN namespace class (no Reactive) — plain mutable
1426
1402
  // fields are its legitimate state; nothing reactive to trigger.
1427
- green: [{ files: { 'src/Box.ts': fixture.validClass.replace("import { ref, watch } from 'vue';", "import { ref, shallowRef, watch } from 'vue';").replace(' get width() {', ' get rows() {\n return shallowRef<number[]>([]);\n }\n get width() {'), 'src/Db.ts': "class $Db {\n connectionCount = 0;\n\n open() {\n this.connectionCount++;\n }\n}\n\nexport namespace Db {\n export const $Class = $Db;\n export let Class = $Class;\n}\n" } }],
1403
+ green: [{ files: { 'src/Box.ts': fixture.validClass.replace("import { ref, watch } from 'vue';", "import { ref, shallowRef, watch } from 'vue';").replace(' get width() {', ' get rows() {\n return shallowRef<number[]>([]);\n }\n get width() {'), 'src/Db.ts': "class $Db {\n connectionCount = 0;\n\n open() {\n this.connectionCount++;\n }\n}\n\nexport namespace Db {\n export const $Class = $Db;\n export let Class = $Class;\n}\n" } }, { files: { 'src/Box.ts': fixture.validClass.replace('class $Box {\n', 'class $Box {\n declare $watch: typeof watch;\n\n') } }],
1428
1404
  },
1429
1405
  'a_ref_is_read_and_written_through_value': {
1430
1406
  claim: 'If class code writes a Ref getter, then it writes .value, never assigns over the getter',
@@ -1505,7 +1481,17 @@ export namespace Scroller {
1505
1481
  },
1506
1482
  expectFindings: [/`area` is a plain getter/, /`grow` is a method/, /`width` shadows the prop/, /reaches a Ref through the instance/],
1507
1483
  }],
1508
- green: [{ files: { ...box, 'src/Box.vue': fixture.validSfc } }],
1484
+ green: [{ files: { 'src/Box.ts': fixture.validClass.replace("import { ref, watch } from 'vue';", "import { ref, watch, type Ref } from 'vue';").replace(' get width() {', ' get forwardedHeight(): Ref<number> {\n return this.height;\n }\n get width() {'), 'src/Box.vue': fixture.validSfc.replace('const { height } = box;', 'const { height, forwardedHeight } = box;') } }, { files: { ...box, 'src/Box.vue': fixture.validSfc } }],
1485
+ },
1486
+ 'one_handler_per_event': {
1487
+ claim: 'If a template binds DOM events on one element, then no two events share a handler: a cancel gets its own method that may delegate to the lift, so a subclass can extend either alone',
1488
+ impossibility: 'a file breaking one_handler_per_event passes the gate',
1489
+ red: [{
1490
+ files: { ...box, 'src/Box.vue': fixture.validSfc.replace('<button @click="box.grow()">grow</button>', '<button @pointerup="box.grow" @pointercancel="box.grow">grow</button>') },
1491
+ expectFindings: [/`@pointerup` and `@pointercancel` bind one handler/],
1492
+ expectCount: 1,
1493
+ }],
1494
+ green: [{ files: { ...box, 'src/Box.vue': fixture.validSfc.replace('<button @click="box.grow()">grow</button>', '<button @pointerup="box.grow" @pointercancel="box.cancelGrow">grow</button>') } }],
1509
1495
  },
1510
1496
  'template_expressions_carry_no_logic': {
1511
1497
  claim: 'If a template expression is written, then it is a named read, a method call, or a structural branch, never a comparison, ternary, negation, or built string',
@@ -1530,7 +1516,7 @@ export namespace Scroller {
1530
1516
  },
1531
1517
  expectFindings: [/constructed in a component's setup but uses `this\.\$watch`/, /no dispose path/, /outlives components .* but uses plain `watch`/],
1532
1518
  }],
1533
- green: [{
1519
+ green: [{ files: { 'src/Box.ts': fixture.validClass.replace("import { ref, watch } from 'vue';", "import { getCurrentScope, onScopeDispose, ref, watch } from 'vue';").replace(' watch(\n () => this.height.value,', ' getCurrentScope() && onScopeDispose(() => this.$stopEffects());\n this.$watch(\n () => this.height.value,'), 'src/Box.vue': fixture.validSfc } }, { files: { ...box, 'src/Host.ts': "import { Reactive } from 'ivue';\nimport { Box } from './Box';\n\nclass $Host {\n constructor() {\n void this.$box;\n }\n\n protected get $box() {\n return new Box.Class({ width: 2 });\n }\n}\n\nexport namespace Host {\n export const $Class = $Host;\n export let Class = Reactive($Class);\n export type Instance = typeof Class.Instance;\n}\n", 'src/Host.vue': "<script setup lang=\"ts\">\nimport { Host } from './Host';\n\nconst host = new Host.Class();\n\ndefineExpose(host as Host.Instance);\n</script>\n\n<template>\n <div />\n</template>\n" } }, {
1534
1520
  files: {
1535
1521
  ...box,
1536
1522
  'src/Box.vue': fixture.validSfc,
@@ -1587,6 +1573,10 @@ export namespace Scroller {
1587
1573
  // main.ts exports nothing — a composition root evaluates after its
1588
1574
  // whole import graph, so its module-evaluation Class read is safe
1589
1575
  files: { ...box, 'src/Shelf.ts': "import { Reactive } from 'ivue';\nimport { Box } from './Box';\n\nclass $Shelf extends Box.$Class {\n make() {\n return new Box.Class({ width: 1 });\n }\n}\n\nexport namespace Shelf {\n export const $Class = $Shelf;\n export let Class = Reactive($Class);\n export type Instance = typeof Class.Instance;\n}\n", 'src/main.ts': "import { Box } from './Box';\n\nconst rootBox = new Box.Class({ width: 1 });\nvoid rootBox.area;\n" },
1576
+ }, {
1577
+ // a generic subclass keeps its type parameter by extending through a cast —
1578
+ // `extends (X.$Class as typeof X.$Class)<T>` is still the heritage read
1579
+ files: { ...box, 'src/Shelf.ts': "import { Reactive } from 'ivue';\nimport { Box } from './Box';\n\nclass $Shelf<T> extends (Box.$Class as typeof Box.$Class) {\n make(item: T) {\n return item;\n }\n}\n\nexport namespace Shelf {\n export const $Class = $Shelf;\n export let Class = Reactive($Class);\n export type Instance = typeof Class.Instance;\n}\n" },
1590
1580
  }],
1591
1581
  },
1592
1582
  'declarations_use_full_descriptive_names': {
@@ -1599,8 +1589,13 @@ export namespace Scroller {
1599
1589
  },
1600
1590
  expectFindings: [/`nv`/, /`e`/, /`inst`/, /`_`/],
1601
1591
  expectCount: 4,
1592
+ }, {
1593
+ // a `v-for` alias is a declaration too
1594
+ files: { ...box, 'src/Box.vue': fixture.validSfc.replace('<button @click="box.grow()">grow</button>', '<ul><li v-for="(r, i) in box.rows" :key="i">{{ r }}</li></ul>\n <button @click="box.grow()">grow</button>') },
1595
+ expectFindings: [/`r` as a `v-for` alias/, /`i` as a `v-for` alias/],
1596
+ expectCount: 2,
1602
1597
  }],
1603
- green: [{ files: { 'src/Box.ts': fixture.validClass.replace(' grow() {', ' offset(px: number, id: string) {\n return `${id}:${px}`;\n }\n\n grow() {'), 'src/Box.test.ts': fixture.validTest } }],
1598
+ green: [{ files: { 'src/Box.ts': fixture.validClass.replace(' grow() {', ' offset(px: number, id: string) {\n return `${id}:${px}`;\n }\n\n grow() {'), 'src/Box.test.ts': fixture.validTest } }, { files: { ...box, 'src/Box.vue': fixture.validSfc.replace('<button @click="box.grow()">grow</button>', '<ul><li v-for="(row, index) in box.rows" :key="index">{{ row }}</li></ul>\n <button @click="box.grow()">grow</button>') } }],
1604
1599
  },
1605
1600
  'class_members_are_ordered_and_spaced': {
1606
1601
  claim: 'If a class is written, then statics precede the constructor, the constructor precedes getters, methods come last and are separated by blank lines',
@@ -1644,15 +1639,15 @@ export namespace Scroller {
1644
1639
  // -------------------------------------------------------------------------
1645
1640
  // behavior — methods (async welcome here; the getters above carry data)
1646
1641
 
1647
- static defineCheck(name: string, run: (context: GateContext) => Finding[]): StandardCheck {
1642
+ static defineCheck(name: string, run: (context: CheckStandard.GateContext) => CheckStandard.Finding[]): CheckStandard.StandardCheck {
1648
1643
  return { name, enforced: true, run };
1649
1644
  }
1650
1645
 
1651
- static finding(check: StandardCheck, unit: SourceUnit, line: number, message: string): Finding {
1646
+ static finding(check: CheckStandard.StandardCheck, unit: CheckStandard.SourceUnit, line: number, message: string): CheckStandard.Finding {
1652
1647
  return { check: check.name, file: unit.relativePath, line, message };
1653
1648
  }
1654
1649
 
1655
- static componentFinding(check: StandardCheck, component: ComponentUnit, line: number, message: string): Finding {
1650
+ static componentFinding(check: CheckStandard.StandardCheck, component: CheckStandard.ComponentUnit, line: number, message: string): CheckStandard.Finding {
1656
1651
  return { check: check.name, file: component.relativePath, line, message };
1657
1652
  }
1658
1653
 
@@ -1682,7 +1677,7 @@ export namespace Scroller {
1682
1677
  }
1683
1678
  }
1684
1679
 
1685
- static toUnit(cwd: string, path: string): SourceUnit {
1680
+ static toUnit(cwd: string, path: string): CheckStandard.SourceUnit {
1686
1681
  const text = readFileSync(path, 'utf8');
1687
1682
  return {
1688
1683
  path,
@@ -1693,11 +1688,12 @@ export namespace Scroller {
1693
1688
  };
1694
1689
  }
1695
1690
 
1696
- static lineOf(unit: SourceUnit, node: ts.Node): number {
1691
+ static lineOf(unit: CheckStandard.SourceUnit, node: ts.Node): number {
1697
1692
  return unit.ast.getLineAndCharacterOfPosition(node.getStart(unit.ast)).line + 1;
1698
1693
  }
1699
1694
 
1700
- static collectTemplateExpressions(nodes: TemplateChildNode[], into: TemplateExpression[]): void {
1695
+ static collectTemplateExpressions(nodes: TemplateChildNode[], into: CheckStandard.TemplateExpression[], aliases: CheckStandard.ForAliases[] = []): void {
1696
+ const aliasNames = (text: string) => text.match(/[A-Za-z_$][\w$]*/g) ?? [];
1701
1697
  for (const node of nodes) {
1702
1698
  if (node.type === NodeTypes.INTERPOLATION && node.content.type === NodeTypes.SIMPLE_EXPRESSION) {
1703
1699
  into.push({ code: node.content.content, line: node.loc.start.line, kind: 'interpolation' });
@@ -1708,31 +1704,34 @@ export namespace Scroller {
1708
1704
  if (this.TEMPLATE_IGNORED_DIRECTIVES.has(property.name)) continue;
1709
1705
  let code = property.exp.content;
1710
1706
  if (property.name === 'for') {
1711
- const source = /\s+(?:in|of)\s+([\s\S]+)$/.exec(code);
1707
+ const source = /^([\s\S]*?)\s+(?:in|of)\s+([\s\S]+)$/.exec(code);
1712
1708
  if (!source) continue;
1713
- code = source[1];
1709
+ aliases.push({ names: aliasNames(source[1]), line: property.exp.loc.start.line });
1710
+ code = source[2];
1714
1711
  }
1715
1712
  into.push({ code, line: property.exp.loc.start.line, kind: property.name });
1716
1713
  }
1717
- this.collectTemplateExpressions(element.children, into);
1714
+ this.collectTemplateExpressions(element.children, into, aliases);
1718
1715
  } else if (node.type === NodeTypes.IF) {
1719
1716
  for (const branch of node.branches) {
1720
1717
  if (branch.condition && branch.condition.type === NodeTypes.SIMPLE_EXPRESSION) into.push({ code: branch.condition.content, line: branch.loc.start.line, kind: 'if' });
1721
- this.collectTemplateExpressions(branch.children, into);
1718
+ this.collectTemplateExpressions(branch.children, into, aliases);
1722
1719
  }
1723
1720
  } else if (node.type === NodeTypes.FOR) {
1724
1721
  if (node.source.type === NodeTypes.SIMPLE_EXPRESSION) into.push({ code: node.source.content, line: node.loc.start.line, kind: 'for' });
1725
- this.collectTemplateExpressions(node.children, into);
1722
+ const names = [node.valueAlias, node.keyAlias, node.objectIndexAlias].flatMap((alias) => (alias && alias.type === NodeTypes.SIMPLE_EXPRESSION ? aliasNames(alias.content) : []));
1723
+ if (names.length) aliases.push({ names, line: node.loc.start.line });
1724
+ this.collectTemplateExpressions(node.children, into, aliases);
1726
1725
  }
1727
1726
  }
1728
1727
  }
1729
1728
 
1730
- static toComponent(cwd: string, path: string): ComponentUnit {
1729
+ static toComponent(cwd: string, path: string): CheckStandard.ComponentUnit {
1731
1730
  const text = readFileSync(path, 'utf8');
1732
1731
  const { descriptor } = parseSfc(text, { filename: path });
1733
1732
  const scriptBlock = descriptor.scriptSetup;
1734
1733
  const scriptLine = scriptBlock ? scriptBlock.loc.start.line : 0;
1735
- const script: SourceUnit | null = scriptBlock
1734
+ const script: CheckStandard.SourceUnit | null = scriptBlock
1736
1735
  ? {
1737
1736
  path,
1738
1737
  relativePath: relative(cwd, path).replaceAll('\\', '/'),
@@ -1741,14 +1740,16 @@ export namespace Scroller {
1741
1740
  ast: ts.createSourceFile(path, scriptBlock.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
1742
1741
  }
1743
1742
  : null;
1744
- const expressions: TemplateExpression[] = [];
1743
+ const expressions: CheckStandard.TemplateExpression[] = [];
1744
+ const forAliases: CheckStandard.ForAliases[] = [];
1745
1745
  if (descriptor.template) {
1746
1746
  const templateAst = parseTemplate(descriptor.template.content, { comments: false });
1747
- this.collectTemplateExpressions(templateAst.children, expressions);
1747
+ this.collectTemplateExpressions(templateAst.children, expressions, forAliases);
1748
1748
  const offset = descriptor.template.loc.start.line - 1;
1749
1749
  for (const expression of expressions) expression.line += offset;
1750
+ for (const alias of forAliases) alias.line += offset;
1750
1751
  }
1751
- return { path, relativePath: relative(cwd, path).replaceAll('\\', '/'), text, script, scriptLine, expressions };
1752
+ return { path, relativePath: relative(cwd, path).replaceAll('\\', '/'), text, script, scriptLine, expressions, forAliases };
1752
1753
  }
1753
1754
 
1754
1755
  /** A template expression parsed as one TypeScript expression (null when it does not parse). */
@@ -1761,12 +1762,12 @@ export namespace Scroller {
1761
1762
  return expression;
1762
1763
  }
1763
1764
 
1764
- static componentLine(component: ComponentUnit, node: ts.Node): number {
1765
+ static componentLine(component: CheckStandard.ComponentUnit, node: ts.Node): number {
1765
1766
  return component.script ? this.lineOf(component.script, node) + component.scriptLine - 1 : 1;
1766
1767
  }
1767
1768
 
1768
1769
  /** `const box = new Box.Class(…)` bindings in a component's script setup. */
1769
- static modelConstructions(component: ComponentUnit): { variable: string; namespace: string; node: ts.Node }[] {
1770
+ static modelConstructions(component: CheckStandard.ComponentUnit): { variable: string; namespace: string; node: ts.Node }[] {
1770
1771
  const constructions: { variable: string; namespace: string; node: ts.Node }[] = [];
1771
1772
  if (!component.script) return constructions;
1772
1773
  for (const statement of component.script.ast.statements) {
@@ -1781,7 +1782,7 @@ export namespace Scroller {
1781
1782
  }
1782
1783
 
1783
1784
  /** Names declared by `defineProps<{ … }>()` / `withDefaults(defineProps<{ … }>(), …)`. */
1784
- static propNames(component: ComponentUnit): Set<string> {
1785
+ static propNames(component: CheckStandard.ComponentUnit): Set<string> {
1785
1786
  const names = new Set<string>();
1786
1787
  if (!component.script) return names;
1787
1788
  this.forEachDescendant(component.script.ast, (node) => {
@@ -1798,7 +1799,7 @@ export namespace Scroller {
1798
1799
  return names;
1799
1800
  }
1800
1801
 
1801
- static classFileOf(unit: SourceUnit): ClassFile | null {
1802
+ static classFileOf(unit: CheckStandard.SourceUnit): CheckStandard.ClassFile | null {
1802
1803
  const dollarClasses = unit.ast.statements.filter(
1803
1804
  (statement): statement is ts.ClassDeclaration =>
1804
1805
  ts.isClassDeclaration(statement) && !!statement.name && statement.name.text.startsWith('$'),
@@ -1851,7 +1852,7 @@ export namespace Scroller {
1851
1852
  };
1852
1853
  }
1853
1854
 
1854
- static classFileByNamespace(context: GateContext, namespace: string): ClassFile | null {
1855
+ static classFileByNamespace(context: CheckStandard.GateContext, namespace: string): CheckStandard.ClassFile | null {
1855
1856
  for (const unit of context.sources) {
1856
1857
  const classFile = this.classFileOf(unit);
1857
1858
  if (classFile?.publicName === namespace) return classFile;
@@ -1867,6 +1868,12 @@ export namespace Scroller {
1867
1868
  return !!(ts.getCombinedModifierFlags(member as ts.Declaration) & ts.ModifierFlags.Readonly);
1868
1869
  }
1869
1870
 
1871
+ /** `declare` on a class member: a type-only statement with no runtime —
1872
+ * how a class names the `$watch` / `$stopEffects` the engine installs. */
1873
+ static isDeclaredMember(member: ts.ClassElement): boolean {
1874
+ return !!ts.getModifiers(member as ts.HasModifiers)?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword);
1875
+ }
1876
+
1870
1877
  static memberName(member: ts.ClassElement): string {
1871
1878
  return member.name && (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) ? member.name.text : '';
1872
1879
  }
@@ -1917,19 +1924,29 @@ export namespace Scroller {
1917
1924
  return isThisMethod ? body : null;
1918
1925
  }
1919
1926
 
1927
+ /** `Ref<…>`, `ShallowRef<…>`, `ComputedRef<…>`, `WritableComputedRef<…>` as a
1928
+ * declared return type. */
1929
+ static isRefTypeNode(type: ts.TypeNode): boolean {
1930
+ return ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName) && ['Ref', 'ShallowRef', 'ComputedRef', 'WritableComputedRef'].includes(type.typeName.text);
1931
+ }
1932
+
1920
1933
  static refFactoryName(expression: ts.Expression | undefined): string | null {
1921
1934
  if (!expression || !ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression)) return null;
1922
1935
  const name = expression.expression.text;
1923
1936
  return ['ref', 'shallowRef', 'computed', 'toRef'].includes(name) ? name : null;
1924
1937
  }
1925
1938
 
1926
- /** Getter names of a class whose body returns a Ref factory call. */
1939
+ /** Getter names of a class whose body returns a Ref factory call, or
1940
+ * that declare a Ref return type — a FORWARDED cell (`get x(): Ref<number>
1941
+ * { return this.$mouse.x }`) is the same cell as its source, and the
1942
+ * annotation is how the author says so. */
1927
1943
  static refGetterNames(rawClass: ts.ClassDeclaration): Set<string> {
1928
1944
  const names = new Set<string>();
1929
1945
  for (const member of rawClass.members) {
1930
1946
  if (!ts.isGetAccessorDeclaration(member) || !member.body) continue;
1931
1947
  const returned = member.body.statements.find(ts.isReturnStatement);
1932
1948
  if (returned && this.refFactoryName(returned.expression)) names.add(this.memberName(member));
1949
+ else if (member.type && this.isRefTypeNode(member.type)) names.add(this.memberName(member));
1933
1950
  }
1934
1951
  return names;
1935
1952
  }
@@ -1939,7 +1956,7 @@ export namespace Scroller {
1939
1956
  node.forEachChild((child) => this.forEachDescendant(child, visit));
1940
1957
  }
1941
1958
 
1942
- static importedBindings(unit: SourceUnit): Set<string> {
1959
+ static importedBindings(unit: CheckStandard.SourceUnit): Set<string> {
1943
1960
  const names = new Set<string>();
1944
1961
  for (const statement of unit.ast.statements) {
1945
1962
  if (!ts.isImportDeclaration(statement) || !statement.importClause) continue;
@@ -1967,10 +1984,10 @@ export namespace Scroller {
1967
1984
  return false;
1968
1985
  }
1969
1986
 
1970
- static parseHeader(unit: SourceUnit): GeneratorHeader {
1987
+ static parseHeader(unit: CheckStandard.SourceUnit): CheckStandard.GeneratorHeader {
1971
1988
  const grammar = this.$grammar;
1972
1989
  const text = unit.text;
1973
- const header: GeneratorHeader = {
1990
+ const header: CheckStandard.GeneratorHeader = {
1974
1991
  present: text.includes(grammar.GENERATOR),
1975
1992
  firstContent: false,
1976
1993
  goal: '',
@@ -2028,10 +2045,10 @@ export namespace Scroller {
2028
2045
  return header;
2029
2046
  }
2030
2047
 
2031
- static parseProofs(unit: SourceUnit, header: GeneratorHeader): ProofAnnotation[] {
2048
+ static parseProofs(unit: CheckStandard.SourceUnit, header: CheckStandard.GeneratorHeader): CheckStandard.ProofAnnotation[] {
2032
2049
  const grammar = this.$grammar;
2033
- const proofs: ProofAnnotation[] = [];
2034
- let pending: ProofAnnotation[] = [];
2050
+ const proofs: CheckStandard.ProofAnnotation[] = [];
2051
+ let pending: CheckStandard.ProofAnnotation[] = [];
2035
2052
  let documentationOpen = false;
2036
2053
  for (let index = header.endLine; index < unit.lines.length; index++) {
2037
2054
  const line = unit.lines[index];
@@ -2108,7 +2125,7 @@ export namespace Scroller {
2108
2125
  /** The run's final severity per check: the receiver's `severities`
2109
2126
  * defaults, overridden by the run's --warn/--off. Unknown names and a
2110
2127
  * check assigned both warn and off are refused. */
2111
- static resolveSeverities(options: GateOptions): Map<string, 'warn' | 'off'> {
2128
+ static resolveSeverities(options: CheckStandard.GateOptions): Map<string, 'warn' | 'off'> {
2112
2129
  const known = this.checkNames;
2113
2130
  const resolved = new Map<string, 'warn' | 'off'>();
2114
2131
  for (const [name, severity] of Object.entries(this.severities)) {
@@ -2128,7 +2145,7 @@ export namespace Scroller {
2128
2145
  return resolved;
2129
2146
  }
2130
2147
 
2131
- static readSkipList(cwd: string, path: string): SkipRow[] {
2148
+ static readSkipList(cwd: string, path: string): CheckStandard.SkipRow[] {
2132
2149
  const absolute = isAbsolute(path) ? path : resolve(cwd, path);
2133
2150
  if (!existsSync(absolute)) throw new CheckStandard.GateUsageError(`skip-list not found: ${path}`);
2134
2151
  let parsed: unknown;
@@ -2138,7 +2155,7 @@ export namespace Scroller {
2138
2155
  throw new CheckStandard.GateUsageError(`skip-list ${path}: not valid JSON (${(error as Error).message}) — the skip list is a JSON array of { path, check, reason }`);
2139
2156
  }
2140
2157
  if (!Array.isArray(parsed)) throw new CheckStandard.GateUsageError(`skip-list ${path}: the skip list is a JSON array of { path, check, reason }`);
2141
- const rows: SkipRow[] = [];
2158
+ const rows: CheckStandard.SkipRow[] = [];
2142
2159
  const seen = new Set<string>();
2143
2160
  const knownNames = this.checkNames;
2144
2161
  parsed.forEach((entry, index) => {
@@ -2158,13 +2175,13 @@ export namespace Scroller {
2158
2175
  }
2159
2176
 
2160
2177
  /** Discover, check, apply the skip-list. Throws GateUsageError on a refused population. */
2161
- static run(options: GateOptions): GateResult {
2178
+ static run(options: CheckStandard.GateOptions): CheckStandard.GateResult {
2162
2179
  const cwd = resolve(options.cwd);
2163
2180
  if (!options.sourceRoots.length) throw new CheckStandard.GateUsageError('at least one --source-root is required');
2164
2181
  const testMatchers = options.testGlobs.map((glob) => ({ glob, regexp: this.globToRegExp(glob) }));
2165
- const sources: SourceUnit[] = [];
2166
- const tests: SourceUnit[] = [];
2167
- const components: ComponentUnit[] = [];
2182
+ const sources: CheckStandard.SourceUnit[] = [];
2183
+ const tests: CheckStandard.SourceUnit[] = [];
2184
+ const components: CheckStandard.ComponentUnit[] = [];
2168
2185
  const isTest = (relativePath: string) => testMatchers.some((matcher) => matcher.regexp.test(relativePath));
2169
2186
  for (const root of options.sourceRoots) {
2170
2187
  const absoluteRoot = isAbsolute(root) ? root : resolve(cwd, root);
@@ -2199,7 +2216,7 @@ export namespace Scroller {
2199
2216
  const skips = options.skipListPath ? this.readSkipList(cwd, options.skipListPath) : [];
2200
2217
  const severities = this.resolveSeverities(options);
2201
2218
 
2202
- const context: GateContext = {
2219
+ const context: CheckStandard.GateContext = {
2203
2220
  cwd,
2204
2221
  sourceRoots: options.sourceRoots.map((root) => (isAbsolute(root) ? root : resolve(cwd, root))),
2205
2222
  sources,
@@ -2208,16 +2225,16 @@ export namespace Scroller {
2208
2225
  testGlobs: options.testGlobs,
2209
2226
  staticImplementation: options.staticImplementation ?? null,
2210
2227
  };
2211
- const raw: Finding[] = [];
2228
+ const raw: CheckStandard.Finding[] = [];
2212
2229
  for (const entry of this.checks) {
2213
2230
  if (!entry.enforced || severities.get(entry.name) === 'off') continue;
2214
2231
  raw.push(...entry.run(context));
2215
2232
  }
2216
2233
 
2217
- const findings: Finding[] = [];
2218
- const warnings: Finding[] = [];
2219
- const suppressed: Finding[] = [];
2220
- const used = new Set<SkipRow>();
2234
+ const findings: CheckStandard.Finding[] = [];
2235
+ const warnings: CheckStandard.Finding[] = [];
2236
+ const suppressed: CheckStandard.Finding[] = [];
2237
+ const used = new Set<CheckStandard.SkipRow>();
2221
2238
  for (const item of raw) {
2222
2239
  const row = skips.find((skip) => skip.check === item.check && skip.path === item.file);
2223
2240
  if (row) {
@@ -2234,7 +2251,7 @@ export namespace Scroller {
2234
2251
  findings.push({ check: this.the_population_and_skip_list_are_exact.name, file: options.skipListPath ?? 'skip-list', line: row.line, message });
2235
2252
  }
2236
2253
  }
2237
- const byPlace = (first: Finding, second: Finding) => first.file.localeCompare(second.file) || first.line - second.line;
2254
+ const byPlace = (first: CheckStandard.Finding, second: CheckStandard.Finding) => first.file.localeCompare(second.file) || first.line - second.line;
2238
2255
  findings.sort(byPlace);
2239
2256
  warnings.sort(byPlace);
2240
2257
  return {
@@ -2251,7 +2268,7 @@ export namespace Scroller {
2251
2268
  /** Run the receiver's whole constitution: every check's red and green
2252
2269
  * arms through run(), refusing a manifest whose check lacks them.
2253
2270
  * `only` isolates one check by name — its arms and nothing else. */
2254
- static prove(options?: { completenessOnly?: boolean; only?: string }): ProveReport {
2271
+ static prove(options?: { completenessOnly?: boolean; only?: string }): CheckStandard.ProveReport {
2255
2272
  const problems: string[] = [];
2256
2273
  const ran = { red: 0, green: 0 };
2257
2274
  const proofs = this.proofs;
@@ -2263,7 +2280,7 @@ export namespace Scroller {
2263
2280
  }
2264
2281
  }
2265
2282
  for (const check of selected) {
2266
- const asGetter = (this as unknown as Record<string, StandardCheck | undefined>)[check.name];
2283
+ const asGetter = (this as unknown as Record<string, CheckStandard.StandardCheck | undefined>)[check.name];
2267
2284
  if (asGetter?.name !== check.name) problems.push(`${check.name}: the name is not its getter — one snake_case form is the whole identity (getter, finding label, skip token, severity key)`);
2268
2285
  const proof = proofs[check.name];
2269
2286
  if (!proof) {
@@ -2285,7 +2302,7 @@ export namespace Scroller {
2285
2302
  writeFileSync(join(checkout, path), text);
2286
2303
  }
2287
2304
  const hasTests = Object.keys(arm.files).some((path) => path.endsWith('.test.ts'));
2288
- const gateOptions: GateOptions = {
2305
+ const gateOptions: CheckStandard.GateOptions = {
2289
2306
  cwd: checkout,
2290
2307
  sourceRoots: ['src'],
2291
2308
  testGlobs: hasTests ? ['src/**/*.test.ts'] : [],
@@ -2299,8 +2316,8 @@ export namespace Scroller {
2299
2316
  // own severities getter must not bend the arm: the check's
2300
2317
  // warnings fold back into its findings.
2301
2318
  const armTestsSeverity = !!arm.options && ('warnChecks' in arm.options || 'offChecks' in arm.options);
2302
- let findings: Finding[] = [];
2303
- let warnings: Finding[] = [];
2319
+ let findings: CheckStandard.Finding[] = [];
2320
+ let warnings: CheckStandard.Finding[] = [];
2304
2321
  let thrown: Error | null = null;
2305
2322
  try {
2306
2323
  const result = this.run(gateOptions);
@@ -2399,7 +2416,7 @@ name, duplicate or stale skip row). Paths in findings are relative to the cwd.`;
2399
2416
  return 2;
2400
2417
  }
2401
2418
  }
2402
- let result: GateResult;
2419
+ let result: CheckStandard.GateResult;
2403
2420
  try {
2404
2421
  result = this.run({ cwd, sourceRoots, testGlobs, skipListPath, staticImplementation: Static });
2405
2422
  } catch (error) {
@@ -2425,6 +2442,174 @@ export namespace CheckStandard {
2425
2442
  export const $Class = Static($CheckStandard);
2426
2443
  export let Class = $Class;
2427
2444
 
2445
+ /* Types — the gate's public vocabulary, one seam */
2446
+
2447
+ export interface Finding {
2448
+ check: string;
2449
+ file: string;
2450
+ line: number;
2451
+ message: string;
2452
+ }
2453
+
2454
+ export type StaticTransform = <Class extends new (...arguments_: any[]) => any>(targetClass: Class) => Class;
2455
+
2456
+ export interface GateOptions {
2457
+ cwd: string;
2458
+ sourceRoots: string[];
2459
+ testGlobs: string[];
2460
+ skipListPath?: string;
2461
+ /** programmatic severity overrides (the declarative home is the gate
2462
+ * class's `severities` getter): demoted to warnings — reported, never
2463
+ * blocking … */
2464
+ warnChecks?: string[];
2465
+ /** … or disabled — not executed, announced in the summary */
2466
+ offChecks?: string[];
2467
+ /** the `Static` used by the runtime probe; defaults to this package's own */
2468
+ staticImplementation?: StaticTransform | null;
2469
+ }
2470
+
2471
+ export interface GateResult {
2472
+ /** blocking findings — checks at severity error */
2473
+ findings: Finding[];
2474
+ /** findings from checks demoted to warn — reported, never blocking */
2475
+ warnings: Finding[];
2476
+ suppressed: Finding[];
2477
+ sources: string[];
2478
+ tests: string[];
2479
+ unenforced: string[];
2480
+ /** checks turned off for this run — announced, never silent */
2481
+ off: string[];
2482
+ }
2483
+
2484
+ export interface StandardCheck {
2485
+ /** The identity: a plain declarative sentence, used verbatim everywhere. */
2486
+ name: string;
2487
+ /** false = registered in the manifest but not enforced yet; the report says so. */
2488
+ enforced: boolean;
2489
+ run(context: GateContext): Finding[];
2490
+ }
2491
+
2492
+ /** One permanent proof fixture: a small checkout the gate runs over. */
2493
+ export interface CheckProofArm {
2494
+ /** repo-relative path → file text; sources under `src/` by convention */
2495
+ files: Record<string, string>;
2496
+ /** package.json for the fixture checkout (default: an ivue consumer) */
2497
+ manifest?: Record<string, unknown>;
2498
+ /** GateOptions overrides for this arm (e.g. a broken staticImplementation) */
2499
+ options?: Partial<GateOptions>;
2500
+ /** red arms: each pattern must match at least one of the check's findings */
2501
+ expectFindings?: (RegExp | string)[];
2502
+ /** red arms: exact number of findings the check must produce */
2503
+ expectCount?: number;
2504
+ /** arms with warn-demoted checks: each pattern must match a warning */
2505
+ expectWarnings?: (RegExp | string)[];
2506
+ /** red arms for population refusals: run() must throw matching this */
2507
+ expectThrows?: RegExp;
2508
+ }
2509
+
2510
+ /** A check's constitution entry: its claim, its boundary, and both arms. */
2511
+ export interface CheckProof {
2512
+ claim: string;
2513
+ impossibility: string;
2514
+ red: CheckProofArm[];
2515
+ green: CheckProofArm[];
2516
+ }
2517
+
2518
+ export interface ProveReport {
2519
+ problems: string[];
2520
+ ran: { red: number; green: number };
2521
+ }
2522
+
2523
+ export interface SourceUnit {
2524
+ path: string;
2525
+ relativePath: string;
2526
+ text: string;
2527
+ lines: string[];
2528
+ ast: ts.SourceFile;
2529
+ }
2530
+
2531
+ /** A `.vue` single-file component: its script setup as TS plus every template expression. */
2532
+ export interface ComponentUnit {
2533
+ path: string;
2534
+ relativePath: string;
2535
+ text: string;
2536
+ script: SourceUnit | null;
2537
+ /** 1-based line of the script block's first line in the .vue file */
2538
+ scriptLine: number;
2539
+ expressions: TemplateExpression[];
2540
+ /** every `v-for` alias (value, key, index) with the line it is declared on */
2541
+ forAliases: ForAliases[];
2542
+ }
2543
+
2544
+ export interface ForAliases {
2545
+ names: string[];
2546
+ line: number;
2547
+ }
2548
+
2549
+ export interface TemplateExpression {
2550
+ code: string;
2551
+ line: number;
2552
+ kind: string;
2553
+ }
2554
+
2555
+ export interface GateContext {
2556
+ cwd: string;
2557
+ /** absolute source roots, as discovered */
2558
+ sourceRoots: string[];
2559
+ sources: SourceUnit[];
2560
+ tests: SourceUnit[];
2561
+ components: ComponentUnit[];
2562
+ testGlobs: string[];
2563
+ staticImplementation: StaticTransform | null;
2564
+ }
2565
+
2566
+ export interface ClassFile {
2567
+ unit: SourceUnit;
2568
+ rawClass: ts.ClassDeclaration;
2569
+ rawName: string;
2570
+ publicName: string;
2571
+ namespace: ts.ModuleDeclaration | null;
2572
+ anchorInitializer: ts.Expression | null;
2573
+ classInitializer: ts.Expression | null;
2574
+ hasInstanceType: boolean;
2575
+ isReactive: boolean;
2576
+ isStaticAnchored: boolean;
2577
+ }
2578
+
2579
+ export interface GeneratorHeader {
2580
+ present: boolean;
2581
+ firstContent: boolean;
2582
+ goal: string;
2583
+ formal: string;
2584
+ described: string;
2585
+ orderedRegisters: boolean;
2586
+ bothRegisters: boolean;
2587
+ subjects: { path: string; line: number }[];
2588
+ domainClaims: Map<string, { symbol: string; claim: string; line: number }>;
2589
+ domainSymbols: Set<string>;
2590
+ impossibilities: Map<string, number>;
2591
+ contractLinks: { text: string; file: string; anchor: string; line: number }[];
2592
+ endLine: number;
2593
+ }
2594
+
2595
+ export interface ProofAnnotation {
2596
+ type: 'domain' | 'impossible' | 'record';
2597
+ symbol?: string;
2598
+ claim?: string;
2599
+ name?: string;
2600
+ contractPath?: string;
2601
+ line: number;
2602
+ bound: boolean;
2603
+ }
2604
+
2605
+ export interface SkipRow {
2606
+ path: string;
2607
+ check: string;
2608
+ reason: string;
2609
+ line: number;
2610
+ }
2611
+
2612
+
2428
2613
  export class GateUsageError extends Error {}
2429
2614
 
2430
2615
  // Entry detection that survives every runner AND subclass gates. The