ivue 2.3.0 → 2.5.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.
@@ -0,0 +1,2464 @@
1
+ /**
2
+ * The ivue Standard gate — an ivue Static() class.
3
+ *
4
+ * Checks a consumer's class sources and test files against the rules of
5
+ * the ivue operating manual (skills/ivue/SKILL.md). Portable by
6
+ * construction: the only inputs are source roots, test globs, and a
7
+ * reasoned skip-list — no repository names, no paths of its own.
8
+ *
9
+ * vite-node node_modules/ivue/skills/ivue/check-standard.ts -- \
10
+ * --source-root src --test-glob 'src/**\/*.test.ts' --skip-list ivue-standards-skip.json
11
+ *
12
+ * Every check has ONE identity: a snake_case declarative sentence
13
+ * (a_class_file_is_named_after_its_class). The same string is the static
14
+ * getter's name, the finding label, the skip-list "check" token, and the
15
+ * severities key — search or replace one form and you have touched every
16
+ * site; prove() refuses a check whose name is not its own getter.
17
+ * The gate carries its own CONSTITUTION as data: `proofs` maps every
18
+ * check to its claim, its impossibility, and permanent red and green
19
+ * fixture arms, and `prove()` runs every arm through the same `run()`
20
+ * the command line uses. All of it reads through `this`, so a subclass
21
+ * that overrides or adds a check getter changes the manifest, the
22
+ * skip-list vocabulary, and the constitution in one gesture:
23
+ *
24
+ * class $HouseGate extends CheckStandard.$Class {
25
+ * static get house_rule(): StandardCheck { … }
26
+ * static get checks() { return [...super.checks, this.house_rule]; }
27
+ * static get proofs() { return { ...super.proofs, [this.house_rule.name]: … }; }
28
+ * }
29
+ *
30
+ * …and a check added without both proof arms is refused by `prove()` —
31
+ * the discipline travels with the gate.
32
+ *
33
+ * The gate refuses to run over nothing: zero discovered sources, a test
34
+ * glob that matches no file, an unknown check name in the skip-list, a
35
+ * duplicate skip row, or a skip row whose finding no longer fires are
36
+ * all errors, never silent passes.
37
+ */
38
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
39
+ import { tmpdir } from 'node:os';
40
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
41
+ import ts from 'typescript';
42
+ import { parse as parseSfc } from '@vue/compiler-sfc';
43
+ import { parse as parseTemplate, NodeTypes, type ElementNode, type TemplateChildNode } from '@vue/compiler-dom';
44
+ import { Static } from '../../lib/Static';
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
+ // ---------------------------------------------------------------------------
208
+ // the gate class — statics only; getters carry data, methods carry behavior
209
+
210
+ class $CheckStandard {
211
+ static readonly EXCLUDED_DIRECTORIES = new Set(['node_modules', 'dist', '.git']);
212
+
213
+ static readonly TEMPLATE_IGNORED_DIRECTIVES = new Set(['slot', 'pre', 'cloak', 'once', 'memo']);
214
+
215
+ static readonly BANNED_NAMES = new Set([
216
+ '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',
218
+ ]);
219
+
220
+ static readonly DOMAIN_TERMS = new Set(['px', 'id', 'fx', 'x', 'y', 'z']);
221
+
222
+ static readonly COMPUTED_JUSTIFICATIONS = ['expensive', 'render-suppression', 'stable-handle'];
223
+
224
+ static readonly SETUP_STATE_CALLS = new Set(['ref', 'shallowRef', 'reactive', 'computed', 'watch', 'watchEffect']);
225
+
226
+ static readonly LIFECYCLE_HOOKS = new Set(['onMounted', 'onUnmounted', 'onBeforeMount', 'onBeforeUnmount', 'onUpdated', 'onActivated', 'onDeactivated']);
227
+
228
+ static readonly TEST_CALL = /\b(?:test|it)(?:\.[A-Za-z]+)?\s*\(/;
229
+
230
+ // The grammar's tokens, assembled at runtime and cached once per
231
+ // receiver: this file is scanned by the invariants checker like any
232
+ // other source, and a literal sentinel or annotation here would read
233
+ // as a header or a tripwire of the gate itself.
234
+ static get $grammar() {
235
+ const GENERATOR = ['===', 'GENERATOR', '==='].join(' ');
236
+ const GENERATOR_DESCRIBED = ['===', 'GENERATOR-DESCRIBED', '==='].join(' ');
237
+ const DOMAIN = 'domain-' + 'invariant';
238
+ const IMPOSSIBLE = 'impossible-if-' + 'true';
239
+ const RECORD = 'inv' + 'ariant';
240
+ return {
241
+ GENERATOR,
242
+ GENERATOR_DESCRIBED,
243
+ DOMAIN,
244
+ IMPOSSIBLE,
245
+ RECORD,
246
+ CONTRACT_SUFFIX: `.${RECORD}s.md`,
247
+ DOMAIN_LINE: new RegExp(`^\\s*(?://\\s*|\\*?\\s*)${DOMAIN}:\\s*(.+?)\\s+—\\s+(.+?)\\s*$`),
248
+ IMPOSSIBLE_LINE: new RegExp(`^\\s*//\\s*${IMPOSSIBLE}:\\s*(.+?)\\s+—\\s+(.+?)\\s*$`),
249
+ RECORD_LINE: new RegExp(`(?<![\\w-])${RECORD}:\\s*([^(\\n]+?)\\s*\\(([^)\\n]*\\.${RECORD}s\\.md)\\)`),
250
+ CONTRACT_LINK: new RegExp(`\\[([^\\]]+)\\]\\(([^)\\s]*\\.${RECORD}s\\.md)(#[^)\\s]*)?\\)`, 'g'),
251
+ };
252
+ }
253
+
254
+ // -------------------------------------------------------------------------
255
+ // the checks — the getter name is the snake_case of the sentence name
256
+
257
+ static get exactly_one_reactive_source_is_installed(): StandardCheck {
258
+ return this.defineCheck('exactly_one_reactive_source_is_installed', (context) => {
259
+ 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
+ const manifests = new Set<string>();
261
+ for (const root of context.sourceRoots) {
262
+ let directory = root;
263
+ for (let depth = 0; depth < 8; depth++) {
264
+ const manifest = join(directory, 'package.json');
265
+ if (existsSync(manifest)) {
266
+ manifests.add(manifest);
267
+ break;
268
+ }
269
+ const parent = dirname(directory);
270
+ if (parent === directory) break;
271
+ directory = parent;
272
+ }
273
+ }
274
+ let dependency = false;
275
+ for (const manifest of manifests) {
276
+ const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
277
+ if (parsed.name === 'ivue' || parsed.dependencies?.ivue || parsed.devDependencies?.ivue) dependency = true;
278
+ }
279
+ const count = vendored.length + (dependency ? 1 : 0);
280
+ if (count === 1) return [];
281
+ const unit = vendored[0] ?? context.sources[0];
282
+ return [
283
+ this.finding(this.exactly_one_reactive_source_is_installed, unit, 1,
284
+ count === 0
285
+ ? 'no Reactive source: neither an ivue dependency in package.json nor a vendored `export function Reactive`'
286
+ : `${count} Reactive sources (dependency: ${dependency}; vendored: ${vendored.map((entry) => entry.relativePath).join(', ')}) — keep exactly one`),
287
+ ];
288
+ });
289
+ }
290
+
291
+ static get a_public_class_publishes_its_namespace_manifest(): StandardCheck {
292
+ return this.defineCheck('a_public_class_publishes_its_namespace_manifest', (context) => {
293
+ const findings: Finding[] = [];
294
+ const unwrap = (expression: ts.Expression): ts.Expression => {
295
+ let current = expression;
296
+ while (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current)) current = current.expression;
297
+ return current;
298
+ };
299
+ const isBehavioralObject = (expression: ts.Expression | undefined): boolean => {
300
+ if (!expression) return false;
301
+ const bare = unwrap(expression);
302
+ return ts.isObjectLiteralExpression(bare) && bare.properties.some((property) => ts.isMethodDeclaration(property) || (ts.isPropertyAssignment(property) && this.isFunctionLike(property.initializer)));
303
+ };
304
+ for (const unit of context.sources) {
305
+ const classFile = this.classFileOf(unit);
306
+ if (classFile) {
307
+ const { rawClass, publicName, namespace, anchorInitializer, classInitializer, isReactive, hasInstanceType } = classFile;
308
+ const line = this.lineOf(unit, rawClass);
309
+ if (!namespace) {
310
+ findings.push(this.finding(this.a_public_class_publishes_its_namespace_manifest, unit, line, `class ${classFile.rawName} has no \`export namespace ${publicName}\``));
311
+ continue;
312
+ }
313
+ if (!anchorInitializer)
314
+ findings.push(this.finding(this.a_public_class_publishes_its_namespace_manifest, unit, this.lineOf(unit, namespace), `namespace ${publicName} lacks \`export const $Class = …\``));
315
+ if (!classInitializer)
316
+ findings.push(this.finding(this.a_public_class_publishes_its_namespace_manifest, unit, this.lineOf(unit, namespace), `namespace ${publicName} lacks \`export let Class = …\``));
317
+ if (isReactive && !hasInstanceType)
318
+ findings.push(this.finding(this.a_public_class_publishes_its_namespace_manifest, unit, this.lineOf(unit, namespace), `reactive namespace ${publicName} lacks \`export type Instance = typeof Class.Instance\``));
319
+ continue;
320
+ }
321
+ for (const statement of unit.ast.statements) {
322
+ const exported = ts.getCombinedModifierFlags(statement as unknown as ts.Declaration) & ts.ModifierFlags.Export;
323
+ if (ts.isClassDeclaration(statement) && exported)
324
+ findings.push(this.finding(this.a_public_class_publishes_its_namespace_manifest, unit, this.lineOf(unit, statement), 'a class is exported directly — publish `$X` through `export namespace X`'));
325
+ if (ts.isExportAssignment(statement) && isBehavioralObject(statement.expression))
326
+ findings.push(this.finding(this.a_public_class_publishes_its_namespace_manifest, unit, this.lineOf(unit, statement), 'a behavioral object is exported directly — behavior belongs to a namespace Static class'));
327
+ if (ts.isVariableStatement(statement) && exported) {
328
+ for (const declaration of statement.declarationList.declarations) {
329
+ if (isBehavioralObject(declaration.initializer))
330
+ findings.push(this.finding(this.a_public_class_publishes_its_namespace_manifest, unit, this.lineOf(unit, declaration), 'a behavioral object is exported directly — behavior belongs to a namespace Static class'));
331
+ }
332
+ }
333
+ }
334
+ }
335
+ return findings;
336
+ });
337
+ }
338
+
339
+ static get a_class_file_is_named_after_its_class(): StandardCheck {
340
+ return this.defineCheck('a_class_file_is_named_after_its_class', (context) => {
341
+ const findings: Finding[] = [];
342
+ for (const unit of context.sources) {
343
+ const classFile = this.classFileOf(unit);
344
+ if (!classFile) continue;
345
+ const stem = basename(unit.path).replace(/\.ts$/, '');
346
+ if (stem !== classFile.publicName)
347
+ findings.push(this.finding(this.a_class_file_is_named_after_its_class, unit, this.lineOf(unit, classFile.rawClass), `file \`${stem}.ts\` declares \`${classFile.rawName}\` — the file, class and namespace share one name`));
348
+ }
349
+ return findings;
350
+ });
351
+ }
352
+
353
+ static get a_class_file_holds_only_imports_class_namespace_and_types(): StandardCheck {
354
+ return this.defineCheck('a_class_file_holds_only_imports_class_namespace_and_types', (context) => {
355
+ const findings: Finding[] = [];
356
+ for (const unit of context.sources) {
357
+ const classFile = this.classFileOf(unit);
358
+ if (!classFile) continue;
359
+ let seenClass = false;
360
+ let seenImportAfterCode = false;
361
+ for (const statement of unit.ast.statements) {
362
+ if (ts.isImportDeclaration(statement) || ts.isImportEqualsDeclaration(statement)) {
363
+ if (seenClass && !seenImportAfterCode) {
364
+ seenImportAfterCode = true;
365
+ findings.push(this.finding(this.a_class_file_holds_only_imports_class_namespace_and_types, unit, this.lineOf(unit, statement), 'imports come first'));
366
+ }
367
+ continue;
368
+ }
369
+ if (statement === classFile.rawClass) {
370
+ seenClass = true;
371
+ continue;
372
+ }
373
+ if (statement === classFile.namespace) {
374
+ 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
+ continue;
376
+ }
377
+ if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isEnumDeclaration(statement)) continue;
378
+ if (ts.isExportDeclaration(statement) && statement.isTypeOnly) continue;
379
+ 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
+ }
381
+ }
382
+ return findings;
383
+ });
384
+ }
385
+
386
+ static get behavior_lives_on_the_prototype_not_in_fields(): StandardCheck {
387
+ return this.defineCheck('behavior_lives_on_the_prototype_not_in_fields', (context) => {
388
+ const findings: Finding[] = [];
389
+ for (const unit of context.sources) {
390
+ const classFile = this.classFileOf(unit);
391
+ if (!classFile) continue;
392
+ for (const member of classFile.rawClass.members) {
393
+ if (ts.isPropertyDeclaration(member) && this.isFunctionLike(member.initializer))
394
+ findings.push(this.finding(this.behavior_lives_on_the_prototype_not_in_fields, unit, this.lineOf(unit, member), `\`${this.memberName(member)}\` is a function-valued field — write it as a method; the engine binds methods lazily`));
395
+ }
396
+ }
397
+ return findings;
398
+ });
399
+ }
400
+
401
+ static get construction_goes_through_the_namespace_class_slot(): StandardCheck {
402
+ return this.defineCheck('construction_goes_through_the_namespace_class_slot', (context) => {
403
+ const findings: Finding[] = [];
404
+ for (const unit of context.sources) {
405
+ this.forEachDescendant(unit.ast, (node) => {
406
+ if (ts.isNewExpression(node)) {
407
+ const callee = node.expression;
408
+ if (ts.isIdentifier(callee) && callee.text.startsWith('$'))
409
+ findings.push(this.finding(this.construction_goes_through_the_namespace_class_slot, unit, this.lineOf(unit, node), `\`new ${callee.text}()\` constructs the raw class — construct \`${callee.text.slice(1)}.Class\``));
410
+ if (ts.isPropertyAccessExpression(callee) && callee.name.text === '$Class')
411
+ 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
+ }
413
+ 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'));
415
+ });
416
+ }
417
+ return findings;
418
+ });
419
+ }
420
+
421
+ static get the_anchor_is_static_only_when_statics_exist(): StandardCheck {
422
+ return this.defineCheck('the_anchor_is_static_only_when_statics_exist', (context) => {
423
+ const findings: Finding[] = [];
424
+ for (const unit of context.sources) {
425
+ const classFile = this.classFileOf(unit);
426
+ if (!classFile?.namespace || !classFile.anchorInitializer) continue;
427
+ const hasStatics = classFile.rawClass.members.some((member) => this.isStaticMember(member) && !ts.isConstructorDeclaration(member));
428
+ const anchorLine = this.lineOf(unit, classFile.anchorInitializer);
429
+ if (hasStatics && !classFile.isStaticAnchored)
430
+ findings.push(this.finding(this.the_anchor_is_static_only_when_statics_exist, unit, anchorLine, `${classFile.rawName} declares statics but the anchor is raw — \`export const $Class = Static(${classFile.rawName})\``));
431
+ if (!hasStatics && classFile.isStaticAnchored)
432
+ findings.push(this.finding(this.the_anchor_is_static_only_when_statics_exist, unit, anchorLine, `${classFile.rawName} declares no statics but the anchor is \`Static(…)\` — \`export const $Class = ${classFile.rawName}\``));
433
+ }
434
+ return findings;
435
+ });
436
+ }
437
+
438
+ static get static_binds_methods_and_caches_dollar_getters_per_receiver(): StandardCheck {
439
+ 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'})` });
442
+ const StaticUnderTest = context.staticImplementation;
443
+ if (!StaticUnderTest) return [probe('`Static` could not be loaded from ivue/extras — the runtime probe did not run')];
444
+ const findings: Finding[] = [];
445
+ let cacheRuns = 0;
446
+ class $Probe {
447
+ static get $cache() {
448
+ cacheRuns++;
449
+ return { receiver: this };
450
+ }
451
+ static method() {
452
+ return this;
453
+ }
454
+ }
455
+ const Anchor = StaticUnderTest($Probe);
456
+ const detached = Anchor.method;
457
+ if (detached !== Anchor.method) findings.push(probe('`Static` does not keep method identity stable across reads'));
458
+ if (detached() !== Anchor) findings.push(probe('`Static` does not bind static methods to the receiving class'));
459
+ const first = Anchor.$cache;
460
+ if (first !== Anchor.$cache || cacheRuns !== 1) findings.push(probe('`Static` does not cache a dollar getter once per receiver'));
461
+ class Sub extends Anchor {}
462
+ const subCache = Sub.$cache;
463
+ if (subCache === first || subCache.receiver !== Sub || cacheRuns !== 2) findings.push(probe('`Static` lets a parent dollar-cache shadow a subclass receiver'));
464
+ if (Sub.method() !== Sub) findings.push(probe('`Static` binds a subclass method to the parent'));
465
+ return findings;
466
+ });
467
+ }
468
+
469
+ static get a_shared_store_is_a_static_readonly_field(): StandardCheck {
470
+ return this.defineCheck('a_shared_store_is_a_static_readonly_field', (context) => {
471
+ const findings: Finding[] = [];
472
+ for (const unit of context.sources) {
473
+ const classFile = this.classFileOf(unit);
474
+ if (!classFile) continue;
475
+ for (const member of classFile.rawClass.members) {
476
+ if (!ts.isPropertyDeclaration(member) || !this.isStaticMember(member) || !member.initializer) continue;
477
+ const initializer = member.initializer;
478
+ const storeShaped =
479
+ ts.isNewExpression(initializer)
480
+ ? !!initializer.expression && ts.isIdentifier(initializer.expression) && ['Map', 'Set', 'WeakMap', 'WeakSet', 'Array'].includes(initializer.expression.text)
481
+ : ts.isObjectLiteralExpression(initializer) || ts.isArrayLiteralExpression(initializer);
482
+ if (storeShaped && !this.isReadonlyMember(member))
483
+ findings.push(this.finding(this.a_shared_store_is_a_static_readonly_field, unit, this.lineOf(unit, member), `static \`${this.memberName(member)}\` is a mutable shared store — declare it \`static readonly\``));
484
+ const constructsNamespaceClass =
485
+ ts.isNewExpression(initializer) &&
486
+ ts.isPropertyAccessExpression(initializer.expression) &&
487
+ initializer.expression.name.text === 'Class';
488
+ if (constructsNamespaceClass)
489
+ findings.push(this.finding(this.a_shared_store_is_a_static_readonly_field, unit, this.lineOf(unit, member), `static \`${this.memberName(member)}\` constructs another namespace's class at module load — hold it in \`new LazyShared(() => …)\``));
490
+ }
491
+ }
492
+ return findings;
493
+ });
494
+ }
495
+
496
+ static get a_derived_static_getter_is_lower_camel_case(): StandardCheck {
497
+ return this.defineCheck('a_derived_static_getter_is_lower_camel_case', (context) => {
498
+ const findings: Finding[] = [];
499
+ const isLiteral = (expression: ts.Expression): boolean => {
500
+ if (ts.isNumericLiteral(expression) || ts.isStringLiteralLike(expression) || ts.isRegularExpressionLiteral(expression) || expression.kind === ts.SyntaxKind.TrueKeyword || expression.kind === ts.SyntaxKind.FalseKeyword) return true;
501
+ if (ts.isPrefixUnaryExpression(expression) && expression.operator === ts.SyntaxKind.MinusToken) return isLiteral(expression.operand);
502
+ if (ts.isBinaryExpression(expression)) return isLiteral(expression.left) && isLiteral(expression.right);
503
+ if (ts.isParenthesizedExpression(expression)) return isLiteral(expression.expression);
504
+ if (ts.isArrayLiteralExpression(expression)) return expression.elements.every((element) => ts.isExpression(element) && isLiteral(element));
505
+ if (ts.isObjectLiteralExpression(expression)) return expression.properties.every((property) => ts.isPropertyAssignment(property) && isLiteral(property.initializer));
506
+ if (ts.isAsExpression(expression)) return isLiteral(expression.expression);
507
+ return false;
508
+ };
509
+ for (const unit of context.sources) {
510
+ const classFile = this.classFileOf(unit);
511
+ if (!classFile) continue;
512
+ for (const member of classFile.rawClass.members) {
513
+ if (!ts.isGetAccessorDeclaration(member) || !this.isStaticMember(member) || !member.body) continue;
514
+ const name = this.memberName(member);
515
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name) || !name.includes('_')) continue;
516
+ const returned = member.body.statements.find(ts.isReturnStatement)?.expression;
517
+ if (returned && isLiteral(returned)) continue;
518
+ 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`));
520
+ }
521
+ }
522
+ return findings;
523
+ });
524
+ }
525
+
526
+ static get static_reads_go_through_self_not_the_base_class(): StandardCheck {
527
+ return this.defineCheck('static_reads_go_through_self_not_the_base_class', (context) => {
528
+ const findings: Finding[] = [];
529
+ for (const unit of context.sources) {
530
+ const classFile = this.classFileOf(unit);
531
+ if (!classFile) continue;
532
+ for (const member of classFile.rawClass.members) {
533
+ if (this.isStaticMember(member)) continue;
534
+ this.forEachDescendant(member, (node) => {
535
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === classFile.rawName)
536
+ findings.push(this.finding(this.static_reads_go_through_self_not_the_base_class, unit, this.lineOf(unit, node), `\`${node.getText(unit.ast)}\` pins the read to the base class — read \`this.self.${node.name.text}\``));
537
+ if (ts.isAsExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'constructor' && node.expression.expression.kind === ts.SyntaxKind.ThisKeyword && !(ts.isGetAccessorDeclaration(member) && this.memberName(member) === 'self'))
538
+ findings.push(this.finding(this.static_reads_go_through_self_not_the_base_class, unit, this.lineOf(unit, node), 'per-site `this.constructor as …` cast — declare `get self()` once and read through it'));
539
+ });
540
+ }
541
+ }
542
+ return findings;
543
+ });
544
+ }
545
+
546
+ static get mutable_state_is_a_ref_returning_getter(): StandardCheck {
547
+ return this.defineCheck('mutable_state_is_a_ref_returning_getter', (context) => {
548
+ const findings: Finding[] = [];
549
+ for (const unit of context.sources) {
550
+ const classFile = this.classFileOf(unit);
551
+ // A plain namespace class (no Reactive) holds plain state — there
552
+ // is no reactivity for a field write to trigger.
553
+ if (!classFile?.isReactive) continue;
554
+ for (const member of classFile.rawClass.members) {
555
+ if (!ts.isPropertyDeclaration(member) || this.isStaticMember(member) || this.isReadonlyMember(member)) continue;
556
+ const initializer = member.initializer;
557
+ const fromFactory =
558
+ !!initializer &&
559
+ ts.isCallExpression(initializer) &&
560
+ ts.isPropertyAccessExpression(initializer.expression) &&
561
+ initializer.expression.expression.kind === ts.SyntaxKind.ThisKeyword &&
562
+ /^create[A-Z]/.test(initializer.expression.name.text);
563
+ if (fromFactory || this.isFunctionLike(initializer)) continue;
564
+ findings.push(this.finding(this.mutable_state_is_a_ref_returning_getter, unit, this.lineOf(unit, member), `\`${this.memberName(member)}\` is a mutable plain field — writes trigger nothing; declare \`get ${this.memberName(member)}() { return ref(…) }\``));
565
+ }
566
+ }
567
+ return findings;
568
+ });
569
+ }
570
+
571
+ static get a_ref_is_read_and_written_through_value(): StandardCheck {
572
+ return this.defineCheck('a_ref_is_read_and_written_through_value', (context) => {
573
+ const findings: Finding[] = [];
574
+ for (const unit of context.sources) {
575
+ const classFile = this.classFileOf(unit);
576
+ if (!classFile) continue;
577
+ const refGetters = this.refGetterNames(classFile.rawClass);
578
+ if (!refGetters.size) continue;
579
+ this.forEachDescendant(classFile.rawClass, (node) => {
580
+ if (!ts.isBinaryExpression(node) || node.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return;
581
+ const target = node.left;
582
+ if (ts.isPropertyAccessExpression(target) && target.expression.kind === ts.SyntaxKind.ThisKeyword && refGetters.has(target.name.text))
583
+ findings.push(this.finding(this.a_ref_is_read_and_written_through_value, unit, this.lineOf(unit, node), `\`this.${target.name.text} = …\` assigns over a Ref getter — write \`this.${target.name.text}.value = …\``));
584
+ });
585
+ }
586
+ return findings;
587
+ });
588
+ }
589
+
590
+ static get a_derivation_is_a_plain_getter_unless_computed_is_justified(): StandardCheck {
591
+ return this.defineCheck('a_derivation_is_a_plain_getter_unless_computed_is_justified', (context) => {
592
+ const findings: Finding[] = [];
593
+ for (const unit of context.sources) {
594
+ const classFile = this.classFileOf(unit);
595
+ if (!classFile) continue;
596
+ for (const member of classFile.rawClass.members) {
597
+ if (!ts.isGetAccessorDeclaration(member) || !member.body) continue;
598
+ const returned = member.body.statements.find(ts.isReturnStatement);
599
+ if (!returned || this.refFactoryName(returned.expression) !== 'computed') continue;
600
+ const leading = unit.text.slice(member.getFullStart(), member.getStart(unit.ast));
601
+ const justified = this.COMPUTED_JUSTIFICATIONS.some((category) => leading.includes(category));
602
+ if (!justified)
603
+ findings.push(this.finding(this.a_derivation_is_a_plain_getter_unless_computed_is_justified, unit, this.lineOf(unit, member), `\`get ${this.memberName(member)}()\` allocates a computed without a stated reason — derive with a plain getter, or justify above it: \`// computed: expensive | render-suppression | stable-handle\``));
604
+ }
605
+ }
606
+ return findings;
607
+ });
608
+ }
609
+
610
+ static get a_composable_is_injected_by_a_one_call_dollar_getter(): StandardCheck {
611
+ return this.defineCheck('a_composable_is_injected_by_a_one_call_dollar_getter', (context) => {
612
+ const findings: Finding[] = [];
613
+ for (const unit of context.sources) {
614
+ const classFile = this.classFileOf(unit);
615
+ if (!classFile) continue;
616
+ for (const member of classFile.rawClass.members) {
617
+ 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}() }\``));
619
+ if (ts.isGetAccessorDeclaration(member) && this.memberName(member).startsWith('$') && member.body) {
620
+ const statements = member.body.statements;
621
+ 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));
622
+ if (!single)
623
+ findings.push(this.finding(this.a_composable_is_injected_by_a_one_call_dollar_getter, unit, this.lineOf(unit, member), `\`get ${this.memberName(member)}()\` does more than one call — a dollar getter creates its singleton and nothing else`));
624
+ }
625
+ }
626
+ }
627
+ return findings;
628
+ });
629
+ }
630
+
631
+ static get instance_types_only_unwrapping_surfaces(): StandardCheck {
632
+ return this.defineCheck('instance_types_only_unwrapping_surfaces', (context) => {
633
+ const findings: Finding[] = [];
634
+ 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) => {
636
+ this.forEachDescendant(unit.ast, (node) => {
637
+ if (ts.isTypeReferenceNode(node)) {
638
+ const tail = this.qualifiedTail(node);
639
+ if (tail?.member === 'Instance') {
640
+ const parent = node.parent;
641
+ const inArray = ts.isArrayTypeNode(parent);
642
+ const inContainer = ts.isTypeReferenceNode(parent) && ts.isIdentifier(parent.typeName) && RAW_CONTAINERS.has(parent.typeName.text);
643
+ const asParameter = ts.isParameter(parent) && !ts.isArrowFunction(parent.parent);
644
+ if (inArray || inContainer || asParameter) report(this.lineOf(unit, node), `\`${tail.namespace}.Instance\` types a raw graph position (collection, ref, or parameter) — raw instances are \`${tail.namespace}.Model\`; \`Instance\` is for unwrapping surfaces only`);
645
+ }
646
+ if (tail?.member === 'Model') {
647
+ const parent = node.parent;
648
+ const inUnwrap = (ts.isAsExpression(parent) && ts.isCallExpression(parent.parent) && ts.isIdentifier(parent.parent.expression) && ['defineExpose', 'reactive'].includes(parent.parent.expression.text)) || (ts.isTypeReferenceNode(parent) && ts.isIdentifier(parent.typeName) && parent.typeName.text === 'ShallowUnwrapRef');
649
+ if (inUnwrap) report(this.lineOf(unit, node), `\`${tail.namespace}.Model\` on an unwrapping surface — type it \`${tail.namespace}.Instance\` (strips readonly so ref writes typecheck)`);
650
+ }
651
+ }
652
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'defineExpose' && node.arguments[0] && ts.isIdentifier(node.arguments[0]))
653
+ report(this.lineOf(unit, node), `\`defineExpose(${node.arguments[0].text})\` exposes the raw type — \`defineExpose(${node.arguments[0].text} as X.Instance)\``);
654
+ });
655
+ };
656
+ for (const unit of context.sources) inspect(unit, (line, message) => findings.push(this.finding(this.instance_types_only_unwrapping_surfaces, unit, line, message)));
657
+ for (const component of context.components) if (component.script) inspect(component.script, (line, message) => findings.push(this.componentFinding(this.instance_types_only_unwrapping_surfaces, component, line + component.scriptLine - 1, message)));
658
+ return findings;
659
+ });
660
+ }
661
+
662
+ static get a_component_has_one_model_owner(): StandardCheck {
663
+ return this.defineCheck('a_component_has_one_model_owner', (context) => {
664
+ const findings: Finding[] = [];
665
+ for (const component of context.components) {
666
+ if (!component.script) continue;
667
+ const constructions = this.modelConstructions(component);
668
+ for (const extra of constructions.slice(1)) findings.push(this.componentFinding(this.a_component_has_one_model_owner, component, this.componentLine(component, extra.node), `a second model is constructed (\`${extra.variable}\`) — one template, one logic owner`));
669
+ }
670
+ return findings;
671
+ });
672
+ }
673
+
674
+ static get script_setup_is_wiring_only(): StandardCheck {
675
+ return this.defineCheck('script_setup_is_wiring_only', (context) => {
676
+ const findings: Finding[] = [];
677
+ for (const component of context.components) {
678
+ if (!component.script) continue;
679
+ for (const statement of component.script.ast.statements) {
680
+ if (ts.isFunctionDeclaration(statement))
681
+ findings.push(this.componentFinding(this.script_setup_is_wiring_only, component, this.componentLine(component, statement), `free function \`${statement.name?.text ?? ''}\` beside the model — behavior belongs on the class as a method`));
682
+ this.forEachDescendant(statement, (node) => {
683
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || !this.SETUP_STATE_CALLS.has(node.expression.text)) return;
684
+ if (this.isInsideClassBody(node)) return;
685
+ findings.push(this.componentFinding(this.script_setup_is_wiring_only, component, this.componentLine(component, node), `\`${node.expression.text}()\` in \`<script setup>\` — component-local reactive behavior beside the class; state, derivations and watchers live in the class`));
686
+ });
687
+ }
688
+ }
689
+ return findings;
690
+ });
691
+ }
692
+
693
+ static get a_lifecycle_hook_delegates_to_one_method(): StandardCheck {
694
+ return this.defineCheck('a_lifecycle_hook_delegates_to_one_method', (context) => {
695
+ const findings: Finding[] = [];
696
+ for (const component of context.components) {
697
+ if (!component.script) continue;
698
+ this.forEachDescendant(component.script.ast, (node) => {
699
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || !this.LIFECYCLE_HOOKS.has(node.expression.text)) return;
700
+ if (this.isInsideClassBody(node)) return;
701
+ // A hook that delegates ONE call to the model is the wiring an
702
+ // outliving store needs (its constructor may run outside any
703
+ // component) — thin bridge allowed, logic is not.
704
+ if (node.arguments.length === 1 && this.thinModelDelegation(node.arguments[0])) return;
705
+ findings.push(this.componentFinding(this.a_lifecycle_hook_delegates_to_one_method, component, this.componentLine(component, node), `\`${node.expression.text}()\` in \`<script setup>\` carries logic — a hook may only delegate one call to the model (\`${node.expression.text}(() => model.method())\`); logic lives in a method`));
706
+ });
707
+ }
708
+ return findings;
709
+ });
710
+ }
711
+
712
+ static get the_state_destructure_is_total(): StandardCheck {
713
+ return this.defineCheck('the_state_destructure_is_total', (context) => {
714
+ const findings: Finding[] = [];
715
+ for (const component of context.components) {
716
+ if (!component.script) continue;
717
+ const props = this.propNames(component);
718
+ for (const construction of this.modelConstructions(component)) {
719
+ const classFile = this.classFileByNamespace(context, construction.namespace);
720
+ if (!classFile) continue;
721
+ const refGetters = this.refGetterNames(classFile.rawClass);
722
+ const plainGetters = new Set<string>();
723
+ const methods = new Set<string>();
724
+ for (const member of classFile.rawClass.members) {
725
+ if (this.isStaticMember(member)) continue;
726
+ const name = this.memberName(member);
727
+ if (ts.isGetAccessorDeclaration(member) && !refGetters.has(name)) plainGetters.add(name);
728
+ if (ts.isMethodDeclaration(member)) methods.add(name);
729
+ }
730
+ for (const statement of component.script.ast.statements) {
731
+ if (!ts.isVariableStatement(statement)) continue;
732
+ for (const declaration of statement.declarationList.declarations) {
733
+ if (!ts.isObjectBindingPattern(declaration.name) || !declaration.initializer || !ts.isIdentifier(declaration.initializer) || declaration.initializer.text !== construction.variable) continue;
734
+ for (const element of declaration.name.elements) {
735
+ const bound = ts.isIdentifier(element.name) ? element.name.text : '';
736
+ const source = element.propertyName && ts.isIdentifier(element.propertyName) ? element.propertyName.text : bound;
737
+ const line = this.componentLine(component, element);
738
+ if (plainGetters.has(source)) findings.push(this.componentFinding(this.the_state_destructure_is_total, component, line, `\`${source}\` is a plain getter — destructuring snapshots a dead value; read \`${construction.variable}.${source}\` dotted`));
739
+ if (methods.has(source)) findings.push(this.componentFinding(this.the_state_destructure_is_total, component, line, `\`${source}\` is a method — keep it dotted (\`${construction.variable}.${source}()\`) unless a profiled hot path says otherwise`));
740
+ if (props.has(bound)) findings.push(this.componentFinding(this.the_state_destructure_is_total, component, line, `state binding \`${bound}\` shadows the prop of the same name in the template`));
741
+ }
742
+ }
743
+ }
744
+ for (const expression of component.expressions) {
745
+ const parsed = this.parseExpression(expression.code);
746
+ if (!parsed) continue;
747
+ this.forEachDescendant(parsed, (node) => {
748
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === construction.variable && refGetters.has(node.name.text))
749
+ findings.push(this.componentFinding(this.the_state_destructure_is_total, component, expression.line, `\`${construction.variable}.${node.name.text}\` reaches a Ref through the instance (always truthy in \`v-if\`) — destructure \`${node.name.text}\` as a state binding`));
750
+ });
751
+ }
752
+ }
753
+ }
754
+ return findings;
755
+ });
756
+ }
757
+
758
+ static get template_expressions_carry_no_logic(): StandardCheck {
759
+ return this.defineCheck('template_expressions_carry_no_logic', (context) => {
760
+ const findings: Finding[] = [];
761
+ const isNamedRead = (expression: ts.Expression): boolean => {
762
+ if (ts.isIdentifier(expression) || expression.kind === ts.SyntaxKind.ThisKeyword) return true;
763
+ if (ts.isPropertyAccessExpression(expression)) return isNamedRead(expression.expression);
764
+ if (ts.isCallExpression(expression))
765
+ return isNamedRead(expression.expression) && expression.arguments.every((argument) => isNamedRead(argument) || ts.isNumericLiteral(argument) || ts.isStringLiteralLike(argument));
766
+ return false;
767
+ };
768
+ const describe = (node: ts.Node): string | null => {
769
+ if (ts.isConditionalExpression(node)) return 'a ternary';
770
+ if (ts.isBinaryExpression(node)) {
771
+ const kind = node.operatorToken.kind;
772
+ if (kind === ts.SyntaxKind.EqualsToken) return 'an assignment';
773
+ if (kind === ts.SyntaxKind.PlusToken) return 'string building or arithmetic';
774
+ if (kind === ts.SyntaxKind.InKeyword) return null;
775
+ return `a \`${node.operatorToken.getText()}\` expression`;
776
+ }
777
+ if (ts.isTemplateExpression(node)) return 'a built string';
778
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken)
779
+ return isNamedRead(node.operand) ? null : 'a negation'; // `!name` reads as a name; `!` on compound logic does not
780
+ if (ts.isPostfixUnaryExpression(node) || (ts.isPrefixUnaryExpression(node) && node.operator !== ts.SyntaxKind.MinusToken)) return 'a mutation';
781
+ if (ts.isNewExpression(node)) return 'construction';
782
+ return null;
783
+ };
784
+ for (const component of context.components) {
785
+ for (const expression of component.expressions) {
786
+ const parsed = this.parseExpression(expression.code);
787
+ if (!parsed) continue;
788
+ let reported = false;
789
+ this.forEachDescendant(parsed, (node) => {
790
+ if (reported) return;
791
+ const what = describe(node);
792
+ if (!what) return;
793
+ reported = true;
794
+ findings.push(this.componentFinding(this.template_expressions_carry_no_logic, component, expression.line, `${what} in the template (\`${expression.code.trim().slice(0, 60)}\`) — name it as a plain getter (or a method when it takes an argument)`));
795
+ });
796
+ }
797
+ }
798
+ return findings;
799
+ });
800
+ }
801
+
802
+ static get watch_lifetime_matches_the_instance_owner(): StandardCheck {
803
+ return this.defineCheck('watch_lifetime_matches_the_instance_owner', (context) => {
804
+ const findings: Finding[] = [];
805
+ const componentScoped = new Set<string>();
806
+ for (const component of context.components) for (const construction of this.modelConstructions(component)) componentScoped.add(construction.namespace);
807
+ const outliving = new Set<string>();
808
+ for (const unit of context.sources) {
809
+ this.forEachDescendant(unit.ast, (node) => {
810
+ 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);
812
+ });
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);
815
+ }
816
+ for (const unit of context.sources) {
817
+ const classFile = this.classFileOf(unit);
818
+ if (!classFile) continue;
819
+ const name = classFile.publicName;
820
+ let usesDollarWatch = false;
821
+ let usesPlainWatch = false;
822
+ let hasDisposePath = false;
823
+ let dollarLine = 0;
824
+ let plainLine = 0;
825
+ this.forEachDescendant(classFile.rawClass, (node) => {
826
+ if (!ts.isCallExpression(node)) return;
827
+ const callee = node.expression;
828
+ if (ts.isPropertyAccessExpression(callee) && callee.expression.kind === ts.SyntaxKind.ThisKeyword) {
829
+ if (callee.name.text === '$watch' || callee.name.text === '$watchEffect') {
830
+ usesDollarWatch = true;
831
+ dollarLine ||= this.lineOf(unit, node);
832
+ }
833
+ if (callee.name.text === '$stopEffects') hasDisposePath = true;
834
+ }
835
+ if (ts.isIdentifier(callee)) {
836
+ if (callee.text === 'watch' || callee.text === 'watchEffect') {
837
+ usesPlainWatch = true;
838
+ plainLine ||= this.lineOf(unit, node);
839
+ }
840
+ if (callee.text === 'onScopeDispose') hasDisposePath = true;
841
+ }
842
+ });
843
+ const isComponentScoped = componentScoped.has(name) && !outliving.has(name);
844
+ 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)`));
846
+ 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
+ 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
+ }
849
+ return findings;
850
+ });
851
+ }
852
+
853
+ static get a_reactive_closure_delegates_to_one_method(): StandardCheck {
854
+ return this.defineCheck('a_reactive_closure_delegates_to_one_method', (context) => {
855
+ const findings: Finding[] = [];
856
+ const reactiveCallees = new Set(['computed', 'watch', 'watchEffect', '$watch', '$watchEffect']);
857
+ for (const unit of context.sources) {
858
+ const classFile = this.classFileOf(unit);
859
+ if (!classFile) continue;
860
+ this.forEachDescendant(classFile.rawClass, (node) => {
861
+ if (!ts.isCallExpression(node)) return;
862
+ const callee = node.expression;
863
+ const calleeName = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : '';
864
+ if (!reactiveCallees.has(calleeName)) return;
865
+ const callbacks: ts.Expression[] = [];
866
+ if (calleeName === 'computed' && node.arguments[0]) {
867
+ const argument = node.arguments[0];
868
+ if (ts.isObjectLiteralExpression(argument)) {
869
+ for (const property of argument.properties) if (ts.isPropertyAssignment(property) && property.initializer) callbacks.push(property.initializer);
870
+ } else callbacks.push(argument);
871
+ } else if (calleeName.endsWith('watch') && node.arguments[1]) callbacks.push(node.arguments[1]);
872
+ else if (calleeName.endsWith('watchEffect') && node.arguments[0]) callbacks.push(node.arguments[0]);
873
+ for (const callback of callbacks) {
874
+ if (ts.isPropertyAccessExpression(callback) && callback.expression.kind === ts.SyntaxKind.ThisKeyword) {
875
+ findings.push(this.finding(this.a_reactive_closure_delegates_to_one_method, unit, this.lineOf(unit, callback), `\`${calleeName}(${callback.getText(unit.ast)})\` passes the method directly — use the arrow form \`() => ${callback.getText(unit.ast)}()\``));
876
+ continue;
877
+ }
878
+ if (!this.delegateCall(callback))
879
+ findings.push(this.finding(this.a_reactive_closure_delegates_to_one_method, unit, this.lineOf(unit, callback), `${calleeName} callback carries logic — delegate to one method: \`() => this.method(…)\``));
880
+ }
881
+ });
882
+ }
883
+ return findings;
884
+ });
885
+ }
886
+
887
+ static get a_store_is_used_lazily_and_swapped_at_the_class_slot(): StandardCheck {
888
+ return this.defineCheck('a_store_is_used_lazily_and_swapped_at_the_class_slot', (context) => {
889
+ const findings: Finding[] = [];
890
+ for (const unit of context.sources) {
891
+ this.forEachDescendant(unit.ast, (node) => {
892
+ if (ts.isNewExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'Class' && !this.isInsideFunctionBody(node))
893
+ findings.push(this.finding(this.a_store_is_used_lazily_and_swapped_at_the_class_slot, unit, this.lineOf(unit, node), `\`${node.getText(unit.ast)}\` constructs a singleton at module load — publish it behind \`use()\` (\`singleton ??= new Class()\`) so it constructs on first touch and tests can swap the \`Class\` slot first`));
894
+ if (ts.isParameter(node) && node.type && ts.isConstructorDeclaration(node.parent)) {
895
+ const tail = this.qualifiedTail(node.type);
896
+ 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() }\``));
898
+ }
899
+ });
900
+ }
901
+ for (const component of context.components) {
902
+ if (!component.script) continue;
903
+ this.forEachDescendant(component.script.ast, (node) => {
904
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== 'defineProps') return;
905
+ const typeArgument = node.typeArguments?.[0];
906
+ if (!typeArgument || !ts.isTypeLiteralNode(typeArgument)) return;
907
+ for (const member of typeArgument.members) {
908
+ if (!ts.isPropertySignature(member) || !member.type || !ts.isIdentifier(member.name)) continue;
909
+ const tail = this.qualifiedTail(member.type);
910
+ const storeShaped = /^(app|store|session|root|shell)$/i.test(member.name.text) || /Store$/.test(tail?.namespace ?? '');
911
+ if (tail && (tail.member === 'Instance' || tail.member === 'Model') && storeShaped)
912
+ findings.push(this.componentFinding(this.a_store_is_used_lazily_and_swapped_at_the_class_slot, component, this.componentLine(component, member), `prop \`${member.name.text}: ${tail.namespace}.${tail.member}\` drills a shared model — a store is reached with \`${tail.namespace}.use()\`, never passed down`));
913
+ }
914
+ });
915
+ }
916
+ return findings;
917
+ });
918
+ }
919
+
920
+ static get keyed_state_creates_on_read_and_peeks_on_write(): StandardCheck {
921
+ return this.defineCheck('keyed_state_creates_on_read_and_peeks_on_write', (context) => {
922
+ const findings: Finding[] = [];
923
+ const REF_TYPES = /\b(?:Ref|ShallowRef|ComputedRef|WritableComputedRef)\s*</;
924
+ for (const unit of context.sources) {
925
+ const classFile = this.classFileOf(unit);
926
+ if (!classFile) continue;
927
+ for (const member of classFile.rawClass.members) {
928
+ if (!ts.isPropertyDeclaration(member)) continue;
929
+ const declared = `${member.type?.getText(unit.ast) ?? ''} ${member.initializer?.getText(unit.ast) ?? ''}`;
930
+ if (!/\bMap\s*</.test(declared) || !REF_TYPES.test(declared)) continue;
931
+ const overlay = this.memberName(member);
932
+ let releases = false;
933
+ const writers: ts.MethodDeclaration[] = [];
934
+ for (const method of classFile.rawClass.members) {
935
+ if (!ts.isMethodDeclaration(method) || !method.body) continue;
936
+ const body = method.body.getText(unit.ast);
937
+ if (new RegExp(`this\\.${overlay}\\.(?:delete|clear)\\(`).test(body)) releases = true;
938
+ if (/^(?:set|write|bump|update|put|apply|invalidate)/.test(this.memberName(method)) && new RegExp(`this\\.${overlay}\\.set\\(`).test(body)) writers.push(method);
939
+ }
940
+ if (!releases) findings.push(this.finding(this.keyed_state_creates_on_read_and_peeks_on_write, unit, this.lineOf(unit, member), `keyed overlay \`${overlay}\` has no release path — no method deletes or clears its entries; a Map of refs cannot GC on its own`));
941
+ for (const writer of writers) findings.push(this.finding(this.keyed_state_creates_on_read_and_peeks_on_write, unit, this.lineOf(unit, writer), `write path \`${this.memberName(writer)}\` creates entries in \`${overlay}\` — writes PEEK (\`get(key)?.value++\`); only reads get-or-create`));
942
+ }
943
+ }
944
+ return findings;
945
+ });
946
+ }
947
+
948
+ static get a_generic_reactive_class_casts_its_constructor(): StandardCheck {
949
+ return this.defineCheck('a_generic_reactive_class_casts_its_constructor', (context) => {
950
+ const findings: Finding[] = [];
951
+ for (const unit of context.sources) {
952
+ const classFile = this.classFileOf(unit);
953
+ if (!classFile?.namespace?.body || !classFile.rawClass.typeParameters?.length || !classFile.isReactive) continue;
954
+ const classText = classFile.classInitializer?.getText(unit.ast) ?? '';
955
+ if (!/as\s+unknown\s+as\s+typeof\s+\$\w+/.test(classText))
956
+ findings.push(this.finding(this.a_generic_reactive_class_casts_its_constructor, unit, this.lineOf(unit, classFile.classInitializer ?? classFile.namespace), `generic ${classFile.rawName}: \`Class\` erases <T> — \`export let Class = Reactive($Class) as unknown as typeof $Class\``));
957
+ const instanceAlias = ts.isModuleBlock(classFile.namespace.body) ? classFile.namespace.body.statements.find((statement): statement is ts.TypeAliasDeclaration => ts.isTypeAliasDeclaration(statement) && statement.name.text === 'Instance') : undefined;
958
+ if (instanceAlias && (!instanceAlias.typeParameters?.length || !/ReactiveInstance\s*</.test(instanceAlias.type.getText(unit.ast))))
959
+ findings.push(this.finding(this.a_generic_reactive_class_casts_its_constructor, unit, this.lineOf(unit, instanceAlias), `generic ${classFile.rawName}: \`Instance\` must carry <T> and apply ReactiveInstance by hand — \`export type Instance<T> = ReactiveInstance<${classFile.rawName}<T>>\``));
960
+ }
961
+ return findings;
962
+ });
963
+ }
964
+
965
+ static get cross_module_class_reads_happen_inside_bodies(): StandardCheck {
966
+ return this.defineCheck('cross_module_class_reads_happen_inside_bodies', (context) => {
967
+ const findings: Finding[] = [];
968
+ for (const unit of context.sources) {
969
+ const imported = this.importedBindings(unit);
970
+ if (!imported.size) continue;
971
+ // A module that exports nothing is a composition root (main.ts): its
972
+ // import graph settles before it evaluates and no module can import
973
+ // it into a cycle, so its module-evaluation Class reads are safe.
974
+ const exportsAnything = unit.ast.statements.some(
975
+ (statement) =>
976
+ ts.isExportAssignment(statement) ||
977
+ ts.isExportDeclaration(statement) ||
978
+ !!(ts.getCombinedModifierFlags(statement as unknown as ts.Declaration) & ts.ModifierFlags.Export),
979
+ );
980
+ if (!exportsAnything) continue;
981
+ this.forEachDescendant(unit.ast, (node) => {
982
+ if (!ts.isPropertyAccessExpression(node) || !ts.isIdentifier(node.expression)) return;
983
+ if (!imported.has(node.expression.text)) return;
984
+ if (node.name.text !== 'Class' && node.name.text !== '$Class') return;
985
+ if (node.parent && ts.isExpressionWithTypeArguments(node.parent) && node.name.text === '$Class') return;
986
+ if (this.isInsideFunctionBody(node)) return;
987
+ 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
+ });
989
+ }
990
+ return findings;
991
+ });
992
+ }
993
+
994
+ static get declarations_use_full_descriptive_names(): StandardCheck {
995
+ return this.defineCheck('declarations_use_full_descriptive_names', (context) => {
996
+ const findings: Finding[] = [];
997
+ const inspect = (unit: SourceUnit) => {
998
+ this.forEachDescendant(unit.ast, (node) => {
999
+ let identifier: ts.Identifier | null = null;
1000
+ if ((ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node)) && ts.isIdentifier(node.name)) identifier = node.name;
1001
+ else if ((ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isPropertyDeclaration(node) || ts.isGetAccessorDeclaration(node)) && node.name && ts.isIdentifier(node.name)) identifier = node.name;
1002
+ if (!identifier) return;
1003
+ const name = identifier.text;
1004
+ const bare = name.replace(/^[$_]+/, '');
1005
+ const single = bare.length === 1 && !this.DOMAIN_TERMS.has(bare);
1006
+ const banned = this.BANNED_NAMES.has(bare.toLowerCase());
1007
+ if (name === '_' || single || banned)
1008
+ findings.push(this.finding(this.declarations_use_full_descriptive_names, unit, this.lineOf(unit, identifier), `\`${name}\` — unfold to the domain word (row, cell, newValue, event…); single letters and abbreviations are not names`));
1009
+ });
1010
+ };
1011
+ for (const unit of context.sources) inspect(unit);
1012
+ for (const unit of context.tests) inspect(unit);
1013
+ return findings;
1014
+ });
1015
+ }
1016
+
1017
+ static get class_members_are_ordered_and_spaced(): StandardCheck {
1018
+ return this.defineCheck('class_members_are_ordered_and_spaced', (context) => {
1019
+ const findings: Finding[] = [];
1020
+ const rank = (member: ts.ClassElement): number => {
1021
+ if (this.isStaticMember(member)) return 0;
1022
+ if (ts.isConstructorDeclaration(member)) return 1;
1023
+ if (ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) return 2;
1024
+ return 3;
1025
+ };
1026
+ const rankName = ['a static member', 'the constructor', 'a getter or field', 'a method'];
1027
+ for (const unit of context.sources) {
1028
+ const classFile = this.classFileOf(unit);
1029
+ if (!classFile) continue;
1030
+ let highest = -1;
1031
+ let previous: ts.ClassElement | null = null;
1032
+ for (const member of classFile.rawClass.members) {
1033
+ const currentRank = rank(member);
1034
+ if (currentRank < highest)
1035
+ findings.push(this.finding(this.class_members_are_ordered_and_spaced, unit, this.lineOf(unit, member), `${rankName[currentRank]} follows ${rankName[highest]} — order is statics, constructor, getters, methods`));
1036
+ highest = Math.max(highest, currentRank);
1037
+ if (previous && ts.isMethodDeclaration(member) && ts.isMethodDeclaration(previous)) {
1038
+ const startLine = unit.ast.getLineAndCharacterOfPosition(member.getFullStart()).line;
1039
+ const previousEndLine = unit.ast.getLineAndCharacterOfPosition(previous.getEnd()).line;
1040
+ const between = unit.lines.slice(previousEndLine + 1, unit.ast.getLineAndCharacterOfPosition(member.getStart(unit.ast)).line);
1041
+ if (!between.some((line) => line.trim() === '') && startLine >= previousEndLine)
1042
+ findings.push(this.finding(this.class_members_are_ordered_and_spaced, unit, this.lineOf(unit, member), `method \`${this.memberName(member)}\` is not separated from the previous method by a blank line — methods are paragraphs`));
1043
+ }
1044
+ previous = member;
1045
+ }
1046
+ }
1047
+ return findings;
1048
+ });
1049
+ }
1050
+
1051
+
1052
+
1053
+
1054
+
1055
+
1056
+
1057
+
1058
+
1059
+
1060
+ static get the_population_and_skip_list_are_exact(): StandardCheck {
1061
+ // enforced by run() itself; its findings and refusals carry this name
1062
+ return this.defineCheck('the_population_and_skip_list_are_exact', () => []);
1063
+ }
1064
+
1065
+
1066
+ /** The manifest, in the Standard's order — reads through `this`, so a
1067
+ * subclass's overridden or added check getters flow into it. */
1068
+ static get checks(): readonly StandardCheck[] {
1069
+ return [
1070
+ this.exactly_one_reactive_source_is_installed,
1071
+ this.a_public_class_publishes_its_namespace_manifest,
1072
+ this.a_class_file_is_named_after_its_class,
1073
+ this.a_class_file_holds_only_imports_class_namespace_and_types,
1074
+ this.behavior_lives_on_the_prototype_not_in_fields,
1075
+ this.construction_goes_through_the_namespace_class_slot,
1076
+ this.the_anchor_is_static_only_when_statics_exist,
1077
+ this.static_binds_methods_and_caches_dollar_getters_per_receiver,
1078
+ this.a_shared_store_is_a_static_readonly_field,
1079
+ this.a_derived_static_getter_is_lower_camel_case,
1080
+ this.static_reads_go_through_self_not_the_base_class,
1081
+ this.mutable_state_is_a_ref_returning_getter,
1082
+ this.a_ref_is_read_and_written_through_value,
1083
+ this.a_derivation_is_a_plain_getter_unless_computed_is_justified,
1084
+ this.a_composable_is_injected_by_a_one_call_dollar_getter,
1085
+ this.instance_types_only_unwrapping_surfaces,
1086
+ this.a_component_has_one_model_owner,
1087
+ this.script_setup_is_wiring_only,
1088
+ this.a_lifecycle_hook_delegates_to_one_method,
1089
+ this.the_state_destructure_is_total,
1090
+ this.template_expressions_carry_no_logic,
1091
+ this.watch_lifetime_matches_the_instance_owner,
1092
+ this.a_reactive_closure_delegates_to_one_method,
1093
+ this.a_store_is_used_lazily_and_swapped_at_the_class_slot,
1094
+ this.keyed_state_creates_on_read_and_peeks_on_write,
1095
+ this.a_generic_reactive_class_casts_its_constructor,
1096
+ this.cross_module_class_reads_happen_inside_bodies,
1097
+ this.declarations_use_full_descriptive_names,
1098
+ this.class_members_are_ordered_and_spaced,
1099
+ this.the_population_and_skip_list_are_exact,
1100
+ ];
1101
+ }
1102
+
1103
+ /** The skip-list vocabulary — per receiver, so house checks are skippable too. */
1104
+ static get checkNames(): ReadonlySet<string> {
1105
+ return new Set(this.checks.map((entry) => entry.name));
1106
+ }
1107
+
1108
+ /** Default severity rulings — a house gate ships its team's here
1109
+ * ({ check_name: 'error' | 'warn' | 'off' }). 'error' is the default for
1110
+ * every check, so listing a check at 'error' is an explicit no-op — a
1111
+ * menu entry waiting to be flipped. The programmatic warnChecks /
1112
+ * offChecks options override per run. */
1113
+ static get severities(): Readonly<Record<string, 'error' | 'warn' | 'off'>> {
1114
+ return {};
1115
+ }
1116
+
1117
+ // shared proof fixtures, assembled once per receiver (grammar tokens
1118
+ // interpolated at runtime so scanners never read them as this file's own)
1119
+ static get $fixtures() {
1120
+ const grammar = this.$grammar;
1121
+ const validClass = `import { ref, watch } from 'vue';
1122
+ import { Reactive } from 'ivue';
1123
+
1124
+ class $Box {
1125
+ constructor(public props: { width: number }) {
1126
+ watch(
1127
+ () => this.height.value,
1128
+ (newHeight, oldHeight) => this.onResize(newHeight, oldHeight),
1129
+ );
1130
+ }
1131
+
1132
+ get height() {
1133
+ return ref(4);
1134
+ }
1135
+ get width() {
1136
+ return this.props.width;
1137
+ }
1138
+ get area() {
1139
+ return this.width * this.height.value;
1140
+ }
1141
+
1142
+ grow() {
1143
+ this.height.value++;
1144
+ }
1145
+
1146
+ onResize(newHeight: number, oldHeight: number) {
1147
+ return newHeight - oldHeight;
1148
+ }
1149
+ }
1150
+
1151
+ export namespace Box {
1152
+ export const $Class = $Box;
1153
+ export let Class = Reactive($Class);
1154
+ export type Instance = typeof Class.Instance;
1155
+ }
1156
+ `;
1157
+ const validTest = `/*
1158
+ ${grammar.GENERATOR}
1159
+ Goal: Prove the box grows by exactly one height unit per grow call and that height never moves on its own.
1160
+ // ${grammar.DOMAIN}: $Box — If grow is called, then height increases by one
1161
+ Impossible if true: height decreases without a grow call
1162
+
1163
+ ${grammar.GENERATOR_DESCRIBED}
1164
+ The $Box height is the only mutable state, so growth is the single write path the tests must hold.
1165
+ */
1166
+ import { expect, test } from 'vitest';
1167
+ import { Box } from './Box';
1168
+
1169
+ // ${grammar.DOMAIN}: $Box — If grow is called, then height increases by one
1170
+ test('grow raises height by one', () => {
1171
+ const box = new Box.Class({ width: 2 });
1172
+ box.grow();
1173
+ expect(box.height.value).toBe(5);
1174
+ });
1175
+
1176
+ // ${grammar.IMPOSSIBLE}: $Box — height decreases without a grow call
1177
+ test('height never decreases on its own', () => {
1178
+ const box = new Box.Class({ width: 2 });
1179
+ expect(box.height.value).toBe(4);
1180
+ });
1181
+ `;
1182
+ const validSfc = `<script setup lang="ts">
1183
+ import { Box } from './Box';
1184
+
1185
+ const props = defineProps<{ width: number }>();
1186
+ const box = new Box.Class(props);
1187
+ const { height } = box;
1188
+
1189
+ defineExpose(box as Box.Instance);
1190
+ </script>
1191
+
1192
+ <template>
1193
+ <div v-if="height > 0">{{ box.area }}</div>
1194
+ <button @click="box.grow()">grow</button>
1195
+ </template>
1196
+ `;
1197
+ const staticClass = `import { Static } from 'ivue/extras';
1198
+
1199
+ class $Clock {
1200
+ static get $zone() {
1201
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
1202
+ }
1203
+
1204
+ static now() {
1205
+ return Date.now();
1206
+ }
1207
+ }
1208
+
1209
+ export namespace Clock {
1210
+ export const $Class = Static($Clock);
1211
+ export let Class = $Class;
1212
+ }
1213
+ `;
1214
+ const selfClass = (reads: string) => `import { Reactive } from 'ivue';
1215
+ import { Static } from 'ivue/extras';
1216
+
1217
+ class $Tooltip {
1218
+ static get DELAY_MS() {
1219
+ return 200;
1220
+ }
1221
+
1222
+ get self() {
1223
+ return this.constructor as typeof $Tooltip;
1224
+ }
1225
+
1226
+ get delay() {
1227
+ ${reads}
1228
+ }
1229
+ }
1230
+
1231
+ export namespace Tooltip {
1232
+ export const $Class = Static($Tooltip);
1233
+ export let Class = Reactive($Class);
1234
+ export type Instance = typeof Class.Instance;
1235
+ }
1236
+ `;
1237
+ const keyedClass = (writePath: string, release: string) => `import { ref, type Ref } from 'vue';
1238
+ import { Reactive } from 'ivue';
1239
+
1240
+ class $Sheet {
1241
+ private readonly cellVersions = new Map<number, Ref<number>>();
1242
+
1243
+ trackCell(cellKey: number): void {
1244
+ let versionRef = this.cellVersions.get(cellKey);
1245
+ if (!versionRef) {
1246
+ versionRef = ref(0);
1247
+ this.cellVersions.set(cellKey, versionRef);
1248
+ }
1249
+ void versionRef.value;
1250
+ }
1251
+
1252
+ ${writePath}
1253
+ ${release}}
1254
+
1255
+ export namespace Sheet {
1256
+ export const $Class = $Sheet;
1257
+ export let Class = Reactive($Class);
1258
+ export type Instance = typeof Class.Instance;
1259
+ }
1260
+ `;
1261
+ const genericClass = (classLine: string, instanceLine: string) => `import { ref } from 'vue';
1262
+ import { Reactive, type ReactiveInstance } from 'ivue';
1263
+
1264
+ class $Scroller<T> {
1265
+ get items() {
1266
+ return ref<T[]>([]);
1267
+ }
1268
+ }
1269
+
1270
+ export namespace Scroller {
1271
+ export const $Class = $Scroller;
1272
+ ${classLine}
1273
+ ${instanceLine}
1274
+ }
1275
+ `;
1276
+ const recordWord = `${grammar.RECORD[0].toUpperCase()}${grammar.RECORD.slice(1)}`;
1277
+ const demoContract = `# demo contract
1278
+
1279
+ ## Reality-based ${grammar.RECORD}s
1280
+
1281
+ ### A box never shrinks by itself
1282
+
1283
+ **${recordWord}:** If no grow call happens, then height stays.
1284
+
1285
+ **Status:** provisional
1286
+
1287
+ ## Chosen ${grammar.RECORD}s
1288
+ `;
1289
+ return { validClass, validTest, validSfc, staticClass, selfClass, keyedClass, genericClass, demoContract };
1290
+ }
1291
+
1292
+ /** The constitution: every check's claim, impossibility, and both
1293
+ * permanent proof arms — per receiver, extended alongside `checks`. */
1294
+ static get proofs(): Readonly<Record<string, CheckProof>> {
1295
+ const fixture = this.$fixtures;
1296
+ const grammar = this.$grammar;
1297
+ const contractName = `demo${grammar.CONTRACT_SUFFIX}`;
1298
+ const box = { 'src/Box.ts': fixture.validClass };
1299
+ const boxAndTest = { ...box, 'src/Box.test.ts': fixture.validTest };
1300
+ const crate = (text: string) => text.replaceAll('Box', 'Crate');
1301
+ const pointerTest = (pointer: string, annotation: string) =>
1302
+ fixture.validTest
1303
+ .replace('Impossible if true:', `${pointer}\nImpossible if true:`)
1304
+ .replace(`// ${grammar.IMPOSSIBLE}: $Box — height decreases without a grow call\ntest('height never decreases on its own'`, `${annotation}// ${grammar.IMPOSSIBLE}: $Box — height decreases without a grow call\ntest('height never decreases on its own'`);
1305
+ return {
1306
+ 'exactly_one_reactive_source_is_installed': {
1307
+ claim: 'If the gate runs over a checkout, then it finds exactly one engine, an ivue dependency or one vendored Reactive, never zero and never two',
1308
+ impossibility: 'a file breaking exactly_one_reactive_source_is_installed passes the gate',
1309
+ red: [{ files: { ...box, 'src/Reactive.ts': 'export function Reactive<C>(targetClass: C): C { return targetClass; }\n' }, expectFindings: [/2 Reactive sources/] }],
1310
+ green: [
1311
+ { files: box },
1312
+ { files: { ...box, 'src/ivue.ts': "export { Reactive } from '../engine/Reactive';\n" }, manifest: { name: 'consumer' } },
1313
+ ],
1314
+ },
1315
+ 'a_public_class_publishes_its_namespace_manifest': {
1316
+ claim: 'If a file declares a dollar-prefixed class, then it exports a namespace with dollar-Class, Class, and Instance for reactive classes, and no behavior is exported directly',
1317
+ impossibility: 'a file breaking a_public_class_publishes_its_namespace_manifest passes the gate',
1318
+ red: [{
1319
+ files: {
1320
+ 'src/Box.ts': fixture.validClass.replace(' export type Instance = typeof Class.Instance;\n', ''),
1321
+ 'src/Tools.ts': 'interface Handler { run(): number }\nexport default { run() { return 1; } } satisfies Handler;\n',
1322
+ },
1323
+ expectFindings: [/lacks `export type Instance/, /behavioral object is exported directly/],
1324
+ expectCount: 2,
1325
+ }],
1326
+ green: [{
1327
+ files: {
1328
+ ...box,
1329
+ 'src/Format.ts': "import { Static } from 'ivue/extras';\n\nclass $Format {\n static orDash(value: string | null) {\n return value ?? '—';\n }\n}\n\nexport namespace Format {\n export const $Class = Static($Format);\n export let Class = $Class;\n}\n",
1330
+ 'src/Store.ts': "class $Store {\n private readonly rows: string[] = [];\n\n save(row: string) {\n this.rows.push(row);\n }\n}\n\nexport namespace Store {\n export const $Class = $Store;\n export let Class = $Class;\n}\n",
1331
+ },
1332
+ }],
1333
+ },
1334
+ 'a_class_file_is_named_after_its_class': {
1335
+ claim: 'If a file declares dollar-X, then the file is X.ts and the namespace is X',
1336
+ impossibility: 'a file breaking a_class_file_is_named_after_its_class passes the gate',
1337
+ red: [{ files: { 'src/Crate.ts': fixture.validClass }, expectFindings: [/`Crate\.ts` declares `\$Box`/] }],
1338
+ // Widget.ts declares a private helper class FIRST — the file's
1339
+ // identity is the class matching the file name, not the first class.
1340
+ 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
+ },
1342
+ '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',
1344
+ 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` } }],
1347
+ },
1348
+ 'behavior_lives_on_the_prototype_not_in_fields': {
1349
+ claim: 'If a class member is a function, then it is a method, never a function-valued field',
1350
+ impossibility: 'a file breaking behavior_lives_on_the_prototype_not_in_fields passes the gate',
1351
+ red: [{ files: { 'src/Box.ts': fixture.validClass.replace(' grow() {\n this.height.value++;\n }', ' grow = () => {\n this.height.value++;\n };') }, expectFindings: [/`grow` is a function-valued field/] }],
1352
+ green: [{ files: box }],
1353
+ },
1354
+ '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',
1356
+ impossibility: 'a file breaking construction_goes_through_the_namespace_class_slot passes the gate',
1357
+ red: [{
1358
+ files: {
1359
+ ...box,
1360
+ 'src/BoxFactory.ts': "import { reactive } from 'vue';\nimport { Box, $Box } from './Box';\n\nclass $BoxFactory {\n makeRaw() {\n return new $Box({ width: 1 });\n }\n\n makeAnchor() {\n return new Box.$Class({ width: 1 });\n }\n\n makeWrapped() {\n return reactive(new Box.Class({ width: 1 }));\n }\n}\n\nexport namespace BoxFactory {\n export const $Class = $BoxFactory;\n export let Class = $Class;\n}\n",
1361
+ },
1362
+ expectCount: 3,
1363
+ }],
1364
+ green: [{
1365
+ files: {
1366
+ ...box,
1367
+ '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
+ },
1369
+ }],
1370
+ },
1371
+ 'the_anchor_is_static_only_when_statics_exist': {
1372
+ claim: 'If a class declares static members, then its anchor is Static of the raw class, and if it declares none, then its anchor is the raw class itself',
1373
+ impossibility: 'a file breaking the_anchor_is_static_only_when_statics_exist passes the gate',
1374
+ red: [{
1375
+ files: {
1376
+ 'src/Clock.ts': fixture.staticClass.replace('export const $Class = Static($Clock);', 'export const $Class = $Clock;'),
1377
+ 'src/Box.ts': fixture.validClass.replace("import { Reactive } from 'ivue';", "import { Reactive } from 'ivue';\nimport { Static } from 'ivue/extras';").replace('export const $Class = $Box;', 'export const $Class = Static($Box);'),
1378
+ },
1379
+ expectCount: 2,
1380
+ }],
1381
+ green: [{ files: { 'src/Clock.ts': fixture.staticClass, ...box } }],
1382
+ },
1383
+ 'static_binds_methods_and_caches_dollar_getters_per_receiver': {
1384
+ 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
+ impossibility: 'a file breaking static_binds_methods_and_caches_dollar_getters_per_receiver passes the gate',
1386
+ 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/] },
1388
+ { files: box, options: { staticImplementation: null }, expectFindings: [/could not be loaded/] },
1389
+ ],
1390
+ green: [{ files: box }],
1391
+ },
1392
+ 'a_shared_store_is_a_static_readonly_field': {
1393
+ claim: 'If a static holds shared state, then the field is readonly, and a dependency constructed at load lives in a LazyShared cell',
1394
+ impossibility: 'a file breaking a_shared_store_is_a_static_readonly_field passes the gate',
1395
+ red: [{
1396
+ files: {
1397
+ ...box,
1398
+ 'src/Registry.ts': "import { Static } from 'ivue/extras';\nimport { Box } from './Box';\n\nclass $Registry {\n static formatters = new Map<string, Intl.DateTimeFormat>();\n static readonly defaultBox = new Box.Class({ width: 1 });\n}\n\nexport namespace Registry {\n export const $Class = Static($Registry);\n export let Class = $Class;\n}\n",
1399
+ },
1400
+ expectFindings: [/mutable shared store/, /constructs another namespace's class at module load/],
1401
+ }],
1402
+ green: [{
1403
+ files: {
1404
+ ...box,
1405
+ 'src/Registry.ts': "import { LazyShared, Static } from 'ivue/extras';\nimport { Box } from './Box';\n\nclass $Registry {\n static readonly formatters = new Map<string, Intl.DateTimeFormat>();\n static readonly sharedBox = new LazyShared(() => new Box.Class({ width: 1 }));\n}\n\nexport namespace Registry {\n export const $Class = Static($Registry);\n export let Class = $Class;\n}\n",
1406
+ },
1407
+ }],
1408
+ },
1409
+ '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',
1411
+ impossibility: 'a file breaking a_derived_static_getter_is_lower_camel_case passes the gate',
1412
+ 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() {') } }],
1414
+ },
1415
+ 'static_reads_go_through_self_not_the_base_class': {
1416
+ claim: 'If instance code reads its own statics, then it reads this.self, never the base class name or a per-site constructor cast',
1417
+ impossibility: 'a file breaking static_reads_go_through_self_not_the_base_class passes the gate',
1418
+ red: [{ files: { 'src/Tooltip.ts': fixture.selfClass('return $Tooltip.DELAY_MS + (this.constructor as typeof $Tooltip).DELAY_MS;') }, expectCount: 2 }],
1419
+ green: [{ files: { 'src/Tooltip.ts': fixture.selfClass('const self = this.self;\n return self.DELAY_MS + self.DELAY_MS;') } }],
1420
+ },
1421
+ 'mutable_state_is_a_ref_returning_getter': {
1422
+ claim: 'If a class holds mutable state, then it is a getter returning ref or shallowRef, never a mutable plain field',
1423
+ impossibility: 'a file breaking mutable_state_is_a_ref_returning_getter passes the gate',
1424
+ red: [{ files: { 'src/Box.ts': fixture.validClass.replace(' get height() {', ' count = 0;\n\n get height() {') }, expectFindings: [/`count` is a mutable plain field/] }],
1425
+ // Db.ts is a PLAIN namespace class (no Reactive) — plain mutable
1426
+ // 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" } }],
1428
+ },
1429
+ 'a_ref_is_read_and_written_through_value': {
1430
+ claim: 'If class code writes a Ref getter, then it writes .value, never assigns over the getter',
1431
+ impossibility: 'a file breaking a_ref_is_read_and_written_through_value passes the gate',
1432
+ red: [{ files: { 'src/Box.ts': fixture.validClass.replace(' this.height.value++;', ' this.height = 9;') }, expectFindings: [/assigns over a Ref getter/] }],
1433
+ green: [{ files: { ...box, 'src/Box.vue': fixture.validSfc } }],
1434
+ },
1435
+ 'a_derivation_is_a_plain_getter_unless_computed_is_justified': {
1436
+ claim: 'If a getter allocates a computed, then a stated reason, expensive or render-suppression or stable-handle, sits above it',
1437
+ impossibility: 'a file breaking a_derivation_is_a_plain_getter_unless_computed_is_justified passes the gate',
1438
+ red: [{ files: { 'src/Box.ts': fixture.validClass.replace("import { ref, watch } from 'vue';", "import { computed, ref, watch } from 'vue';").replace(' get area() {\n return this.width * this.height.value;\n }', ' get area() {\n return computed(() => this.width * this.height.value);\n }') }, expectFindings: [/without a stated reason/] }],
1439
+ green: [{ files: { 'src/Box.ts': fixture.validClass.replace("import { ref, watch } from 'vue';", "import { computed, ref, watch } from 'vue';").replace(' grow() {', ' // computed: expensive — sorts every row\n get sortedRows() {\n return computed(() => this.sortRows());\n }\n\n sortRows() {\n return [this.area];\n }\n\n grow() {') } }],
1440
+ },
1441
+ 'a_composable_is_injected_by_a_one_call_dollar_getter': {
1442
+ claim: 'If a class uses a composable or store, then a dollar getter returns the one call, never an eager field',
1443
+ impossibility: 'a file breaking a_composable_is_injected_by_a_one_call_dollar_getter passes the gate',
1444
+ red: [{ files: { 'src/Box.ts': fixture.validClass.replace(' get height() {', " mouse = useMouse();\n\n private get $project() {\n const store = useProjectStore();\n store.warm();\n return store;\n }\n\n get height() {").replace("import { ref, watch } from 'vue';", "import { ref, watch } from 'vue';\nimport { useMouse } from '@vueuse/core';\nimport { useProjectStore } from './stores';") }, expectFindings: [/runs at construction/, /does more than one call/] }],
1445
+ green: [{ files: { 'src/Box.ts': fixture.validClass.replace(' get height() {', ' private get $project() {\n return useProjectStore();\n }\n\n get height() {').replace("import { ref, watch } from 'vue';", "import { ref, watch } from 'vue';\nimport { useProjectStore } from './stores';") } }],
1446
+ },
1447
+ 'instance_types_only_unwrapping_surfaces': {
1448
+ claim: 'If a raw collection or parameter is typed, then it uses Model, and if an unwrapping surface is typed, then it uses Instance',
1449
+ impossibility: 'a file breaking instance_types_only_unwrapping_surfaces passes the gate',
1450
+ red: [{
1451
+ files: {
1452
+ ...box,
1453
+ 'src/Shelf.ts': "import { shallowRef } from 'vue';\nimport { Reactive } from 'ivue';\nimport { Box } from './Box';\n\nclass $Shelf {\n get boxes() {\n return shallowRef<Box.Instance[]>([]);\n }\n\n widest(box: Box.Instance) {\n return box.area;\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",
1454
+ 'src/Box.vue': fixture.validSfc.replace('defineExpose(box as Box.Instance);', 'defineExpose(box as Box.Model);'),
1455
+ },
1456
+ expectFindings: [/types a raw graph position/, /`Box\.Model` on an unwrapping surface/],
1457
+ expectCount: 3,
1458
+ }],
1459
+ green: [{
1460
+ files: {
1461
+ 'src/Box.ts': fixture.validClass.replace(' export type Instance = typeof Class.Instance;', ' export type Model = InstanceType<typeof Class>;\n export type Instance = typeof Class.Instance;'),
1462
+ 'src/Shelf.ts': "import { shallowRef } from 'vue';\nimport { Reactive } from 'ivue';\nimport { Box } from './Box';\n\nclass $Shelf {\n get boxes() {\n return shallowRef<Box.Model[]>([]);\n }\n\n widest(box: Box.Model) {\n return box.area;\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",
1463
+ 'src/Box.vue': fixture.validSfc,
1464
+ },
1465
+ }],
1466
+ },
1467
+ 'a_component_has_one_model_owner': {
1468
+ claim: 'If a component constructs models, then exactly one instance owns its template',
1469
+ impossibility: 'a file breaking a_component_has_one_model_owner passes the gate',
1470
+ red: [{
1471
+ files: { ...box, 'src/Box.vue': fixture.validSfc.replace('const box = new Box.Class(props);', 'const box = new Box.Class(props);\nconst spare = new Box.Class(props);') },
1472
+ expectFindings: [/second model is constructed \(`spare`\)/],
1473
+ expectCount: 1,
1474
+ }],
1475
+ green: [{ files: { ...box, 'src/Box.vue': fixture.validSfc } }],
1476
+ },
1477
+ 'script_setup_is_wiring_only': {
1478
+ claim: 'If a component has a model, then its script setup declares no parallel state, derivation, watcher, or free function',
1479
+ impossibility: 'a file breaking script_setup_is_wiring_only passes the gate',
1480
+ red: [{
1481
+ files: { ...box, 'src/Box.vue': fixture.validSfc.replace('const box = new Box.Class(props);', "import { ref, watch } from 'vue';\nconst box = new Box.Class(props);\nconst open = ref(false);\nwatch(open, () => box.grow());\nfunction toggle() { open.value = !open.value; }") },
1482
+ expectFindings: [/`ref\(\)` in `<script setup>`/, /`watch\(\)` in `<script setup>`/, /free function `toggle`/],
1483
+ expectCount: 3,
1484
+ }],
1485
+ green: [{ files: { ...box, 'src/Box.vue': fixture.validSfc } }],
1486
+ },
1487
+ 'a_lifecycle_hook_delegates_to_one_method': {
1488
+ claim: 'If a lifecycle hook is registered in script setup, then its whole body delegates one call to the model',
1489
+ impossibility: 'a file breaking a_lifecycle_hook_delegates_to_one_method passes the gate',
1490
+ red: [{
1491
+ files: { ...box, 'src/Box.vue': fixture.validSfc.replace('const box = new Box.Class(props);', "import { onMounted } from 'vue';\nconst box = new Box.Class(props);\nonMounted(() => {\n box.grow();\n box.grow();\n});") },
1492
+ expectFindings: [/`onMounted\(\)` in `<script setup>` carries logic/],
1493
+ expectCount: 1,
1494
+ }],
1495
+ // the thin bridge an outliving store needs: one call, nothing else
1496
+ green: [{ files: { ...box, 'src/Box.vue': fixture.validSfc.replace("const { height } = box;", "const { height } = box;\n\nonMounted(() => box.grow());").replace("import { Box } from './Box';", "import { onMounted } from 'vue';\nimport { Box } from './Box';") } }],
1497
+ },
1498
+ 'the_state_destructure_is_total': {
1499
+ claim: 'If a template touches a Ref, then that Ref is destructured, no plain getter or method is destructured, and no state binding shadows a prop',
1500
+ impossibility: 'a file breaking the_state_destructure_is_total passes the gate',
1501
+ red: [{
1502
+ files: {
1503
+ ...box,
1504
+ 'src/Box.vue': "<script setup lang=\"ts\">\nimport { Box } from './Box';\n\nconst props = defineProps<{ width: number }>();\nconst box = new Box.Class(props);\nconst { area, grow, width } = box;\n\ndefineExpose(box as Box.Instance);\n</script>\n\n<template>\n <div v-if=\"box.height\">{{ area }}</div>\n <button @click=\"grow()\">{{ width }}</button>\n</template>\n",
1505
+ },
1506
+ expectFindings: [/`area` is a plain getter/, /`grow` is a method/, /`width` shadows the prop/, /reaches a Ref through the instance/],
1507
+ }],
1508
+ green: [{ files: { ...box, 'src/Box.vue': fixture.validSfc } }],
1509
+ },
1510
+ 'template_expressions_carry_no_logic': {
1511
+ 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',
1512
+ impossibility: 'a file breaking template_expressions_carry_no_logic passes the gate',
1513
+ red: [{
1514
+ files: { ...box, 'src/Box.vue': fixture.validSfc.replace('<div v-if="height > 0">{{ box.area }}</div>', '<div v-if="height > 0 && box.area">{{ box.area ? \'big\' : \'small\' }}</div>\n <span :title="`Box ${box.area}`">{{ !!height }}</span>') },
1515
+ expectFindings: [/`&&` expression/, /a ternary/, /a built string/, /a negation/],
1516
+ expectCount: 4,
1517
+ }],
1518
+ // a bare `!` on a NAMED read (state binding, getter, or method call)
1519
+ // stays name-level — only unnamed compound logic is flagged
1520
+ green: [{ files: { ...box, 'src/Box.vue': fixture.validSfc.replace('<div v-if="height > 0">{{ box.area }}</div>', '<div v-if="box.hasHeight">{{ box.area }}</div>\n <span v-if="!box.hasHeight">empty</span>\n <ul><li v-for="row in box.rows" :key="row.id" :class="{ wide: box.isWide(row), narrow: !box.isWide(row) }">{{ row.name }}</li></ul>') } }],
1521
+ },
1522
+ 'watch_lifetime_matches_the_instance_owner': {
1523
+ claim: 'If a class is component-scoped, then it uses plain watch, and if it outlives components, then it uses dollar-watch with a dispose path',
1524
+ impossibility: 'a file breaking watch_lifetime_matches_the_instance_owner passes the gate',
1525
+ red: [{
1526
+ files: {
1527
+ 'src/Box.ts': fixture.validClass.replace(' watch(\n () => this.height.value,', ' this.$watch(\n () => this.height.value,'),
1528
+ 'src/Box.vue': fixture.validSfc,
1529
+ 'src/Session.ts': "import { ref, watch } from 'vue';\nimport { Reactive } from 'ivue';\n\nclass $Session {\n constructor() {\n watch(() => this.user.value, (user) => this.onUser(user));\n }\n\n get user() {\n return ref<string | null>(null);\n }\n\n onUser(user: string | null) {\n return user;\n }\n}\n\nexport namespace Session {\n export const $Class = $Session;\n export let Class = Reactive($Class);\n export type Instance = typeof Class.Instance;\n\n let singleton: Instance | null = null;\n export function use(): Instance {\n return (singleton ??= new Class());\n }\n}\n",
1530
+ },
1531
+ expectFindings: [/constructed in a component's setup but uses `this\.\$watch`/, /no dispose path/, /outlives components .* but uses plain `watch`/],
1532
+ }],
1533
+ green: [{
1534
+ files: {
1535
+ ...box,
1536
+ 'src/Box.vue': fixture.validSfc,
1537
+ 'src/Session.ts': "import { ref } from 'vue';\nimport { Reactive } from 'ivue';\n\nclass $Session {\n constructor() {\n this.$watch(() => this.user.value, (user) => this.onUser(user));\n }\n\n get user() {\n return ref<string | null>(null);\n }\n\n onUser(user: string | null) {\n return user;\n }\n\n dispose() {\n this.$stopEffects();\n }\n}\n\nexport namespace Session {\n export const $Class = $Session;\n export let Class = Reactive($Class);\n export type Instance = typeof Class.Instance;\n\n let singleton: Instance | null = null;\n export function use(): Instance {\n return (singleton ??= new Class());\n }\n}\n",
1538
+ },
1539
+ }],
1540
+ },
1541
+ 'a_reactive_closure_delegates_to_one_method': {
1542
+ claim: 'If a computed or watch callback is written, then it is one arrow delegating to one method',
1543
+ impossibility: 'a file breaking a_reactive_closure_delegates_to_one_method passes the gate',
1544
+ red: [{
1545
+ files: { 'src/Box.ts': fixture.validClass.replace("import { ref, watch } from 'vue';", "import { computed, ref, watch } from 'vue';").replace(' (newHeight, oldHeight) => this.onResize(newHeight, oldHeight),', ' (newHeight) => {\n if (newHeight > 10) this.grow();\n },').replace(' grow() {', ' // computed: expensive\n get doubled() {\n return computed(this.grow);\n }\n\n grow() {') },
1546
+ expectFindings: [/watch callback carries logic/, /passes the method directly/],
1547
+ }],
1548
+ green: [{ files: box }],
1549
+ },
1550
+ 'a_store_is_used_lazily_and_swapped_at_the_class_slot': {
1551
+ claim: 'If shared state is published, then it constructs lazily behind use and is never drilled as a prop or constructor argument',
1552
+ impossibility: 'a file breaking a_store_is_used_lazily_and_swapped_at_the_class_slot passes the gate',
1553
+ red: [{
1554
+ files: {
1555
+ 'src/Box.ts': `${fixture.validClass}\nexport const store = new Box.Class({ width: 1 });\n`,
1556
+ 'src/Box.vue': fixture.validSfc.replace('defineProps<{ width: number }>()', 'defineProps<{ width: number; app: Box.Instance }>()'),
1557
+ },
1558
+ expectFindings: [/constructs a singleton at module load/, /prop `app: Box\.Instance` drills a shared model/],
1559
+ }],
1560
+ green: [{
1561
+ files: {
1562
+ 'src/Box.ts': fixture.validClass.replace(' export type Instance = typeof Class.Instance;\n}', ' export type Instance = typeof Class.Instance;\n\n let singleton: Instance | null = null;\n export function use(): Instance {\n return (singleton ??= new Class({ width: 1 }));\n }\n}'),
1563
+ 'src/Box.vue': fixture.validSfc.replace('const box = new Box.Class(props);', 'const box = Box.use();'),
1564
+ },
1565
+ }],
1566
+ },
1567
+ 'keyed_state_creates_on_read_and_peeks_on_write': {
1568
+ claim: 'If a class holds a Map of refs, then reads get-or-create, writes peek, and a release path exists',
1569
+ impossibility: 'a file breaking keyed_state_creates_on_read_and_peeks_on_write passes the gate',
1570
+ red: [{ files: { 'src/Sheet.ts': fixture.keyedClass('bumpCell(cellKey: number): void {\n let versionRef = this.cellVersions.get(cellKey);\n if (!versionRef) {\n versionRef = ref(0);\n this.cellVersions.set(cellKey, versionRef);\n }\n versionRef.value++;\n }\n', '') }, expectFindings: [/no release path/, /write path `bumpCell` creates entries/] }],
1571
+ green: [{ files: { 'src/Sheet.ts': fixture.keyedClass('bumpCell(cellKey: number): void {\n const versionRef = this.cellVersions.get(cellKey);\n if (versionRef) versionRef.value++;\n }\n', '\n releaseCell(cellKey: number): void {\n this.cellVersions.delete(cellKey);\n }\n') } }],
1572
+ },
1573
+ 'a_generic_reactive_class_casts_its_constructor': {
1574
+ claim: 'If a reactive class is generic, then Class is cast back to typeof dollar-Class and Instance applies ReactiveInstance by hand',
1575
+ impossibility: 'a file breaking a_generic_reactive_class_casts_its_constructor passes the gate',
1576
+ red: [{ files: { 'src/Scroller.ts': fixture.genericClass('export let Class = Reactive($Class);', 'export type Instance = typeof Class.Instance;') }, expectFindings: [/`Class` erases <T>/, /`Instance` must carry <T>/] }],
1577
+ green: [{ files: { 'src/Scroller.ts': fixture.genericClass('export let Class = Reactive($Class) as unknown as typeof $Class;', 'export type Instance<T> = ReactiveInstance<$Scroller<T>>;') } }],
1578
+ },
1579
+ 'cross_module_class_reads_happen_inside_bodies': {
1580
+ claim: "If a module reads another namespace's Class, then it does so inside a getter or method body, never at module evaluation",
1581
+ impossibility: 'a file breaking cross_module_class_reads_happen_inside_bodies passes the gate',
1582
+ red: [{
1583
+ files: { ...box, 'src/Shelf.ts': "import { Reactive } from 'ivue';\nimport { Box } from './Box';\n\nconst BoxClass = Box.Class;\n\nclass $Shelf {\n make() {\n return new BoxClass({ 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" },
1584
+ expectFindings: [/`Box\.Class` is read at module evaluation/],
1585
+ }],
1586
+ green: [{
1587
+ // main.ts exports nothing — a composition root evaluates after its
1588
+ // whole import graph, so its module-evaluation Class read is safe
1589
+ 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" },
1590
+ }],
1591
+ },
1592
+ 'declarations_use_full_descriptive_names': {
1593
+ claim: 'If a name is declared in source or tests, then it is a domain word, never a single letter or a banned abbreviation',
1594
+ impossibility: 'a file breaking declarations_use_full_descriptive_names passes the gate',
1595
+ red: [{
1596
+ files: {
1597
+ 'src/Box.ts': fixture.validClass.replace(' onResize(newHeight: number, oldHeight: number) {\n return newHeight - oldHeight;\n }', ' onResize(nv: number, e: number) {\n const inst = nv - e;\n return inst;\n }'),
1598
+ 'src/Box.test.ts': fixture.validTest.replace("test('height never decreases on its own', () => {", "test('height never decreases on its own', (_) => {"),
1599
+ },
1600
+ expectFindings: [/`nv`/, /`e`/, /`inst`/, /`_`/],
1601
+ expectCount: 4,
1602
+ }],
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 } }],
1604
+ },
1605
+ 'class_members_are_ordered_and_spaced': {
1606
+ claim: 'If a class is written, then statics precede the constructor, the constructor precedes getters, methods come last and are separated by blank lines',
1607
+ impossibility: 'a file breaking class_members_are_ordered_and_spaced passes the gate',
1608
+ red: [{
1609
+ files: { 'src/Box.ts': fixture.validClass.replace('class $Box {\n constructor', 'class $Box {\n get spare() {\n return ref(0);\n }\n\n constructor').replace(' grow() {\n this.height.value++;\n }\n\n onResize', ' static get LIMIT() {\n return 9;\n }\n\n grow() {\n this.height.value++;\n }\n onResize') },
1610
+ expectFindings: [/the constructor follows a getter or field/, /a static member follows a getter or field/, /`onResize` is not separated from the previous method/],
1611
+ expectCount: 3,
1612
+ }],
1613
+ green: [{ files: { 'src/Box.ts': fixture.validClass.replace('class $Box {\n constructor', 'class $Box {\n static get LIMIT() {\n return 9;\n }\n\n constructor') } }],
1614
+ },
1615
+ 'the_population_and_skip_list_are_exact': {
1616
+ claim: 'If the gate runs, then it refuses zero files, unmatched globs, unknown check names, duplicate and stale skips, and unknown or conflicting severity overrides',
1617
+ impossibility: 'a file breaking the_population_and_skip_list_are_exact passes the gate',
1618
+ red: [
1619
+ { files: { 'src/.keep': '' }, expectThrows: /no source files discovered/ },
1620
+ { files: box, options: { testGlobs: ['src/**/*.test.ts'] }, expectThrows: /test glob matches no file/ },
1621
+ { files: { ...box, 'skips.json': JSON.stringify([{ path: 'src/Box.ts', check: 'No such check', reason: 'reason' }]) }, options: { skipListPath: 'skips.json' }, expectThrows: /unknown check name/ },
1622
+ { files: { ...box, 'skips.json': JSON.stringify([{ path: 'src/Box.ts', check: 'a_class_file_is_named_after_its_class', reason: 'first' }, { path: 'src/Box.ts', check: 'a_class_file_is_named_after_its_class', reason: 'second' }]) }, options: { skipListPath: 'skips.json' }, expectThrows: /duplicate skip/ },
1623
+ { files: { ...box, 'skips.json': JSON.stringify([{ path: 'src/Box.ts', check: 'a_class_file_is_named_after_its_class', reason: 'never fires here' }, { path: 'src/Gone.ts', check: 'a_class_file_is_named_after_its_class', reason: 'file removed' }]) }, options: { skipListPath: 'skips.json' }, expectFindings: [/no longer fires on src\/Box\.ts/, /src\/Gone\.ts does not exist/] },
1624
+ { files: { ...box, 'skips.json': 'src/Box.ts\tA class file is named after its class\treason\n' }, options: { skipListPath: 'skips.json' }, expectThrows: /a JSON array of \{ path, check, reason \}/ },
1625
+ { files: { ...box, 'skips.json': JSON.stringify([{ path: 'src/Box.ts', check: 'a_class_file_is_named_after_its_class' }]) }, options: { skipListPath: 'skips.json' }, expectThrows: /entry 1: .*reason/ },
1626
+ { files: box, options: { warnChecks: ['No such check'] }, expectThrows: /unknown check name/ },
1627
+ { files: box, options: { warnChecks: ['a_class_file_is_named_after_its_class'], offChecks: ['a_class_file_is_named_after_its_class'] }, expectThrows: /both warn and off/ },
1628
+ ],
1629
+ // Crate.ts deliberately declares $Box — the naming check fires and
1630
+ // the skip row suppresses it, proving a used skip is not stale.
1631
+ // Crate.ts deliberately declares $Box — the naming check fires and
1632
+ // the JSON row suppresses it, proving a used skip is not stale
1633
+ green: [
1634
+ { files: { 'src/Crate.ts': fixture.validClass, 'src/Crate.test.ts': fixture.validTest, 'skips.json': JSON.stringify([{ path: 'src/Crate.ts', check: 'a_class_file_is_named_after_its_class', reason: 'legacy file name kept for the public import path' }], null, 2) }, options: { skipListPath: 'skips.json' } },
1635
+ // demoted to warn: the breach reports as a warning, blocks nothing
1636
+ { files: { 'src/Crate.ts': fixture.validClass, 'src/Crate.test.ts': fixture.validTest }, options: { warnChecks: ['a_class_file_is_named_after_its_class'] }, expectWarnings: [/`Crate\.ts` declares `\$Box`/] },
1637
+ // off: the check does not run at all — no finding, no warning
1638
+ { files: { 'src/Crate.ts': fixture.validClass, 'src/Crate.test.ts': fixture.validTest }, options: { offChecks: ['a_class_file_is_named_after_its_class'] } },
1639
+ ],
1640
+ },
1641
+ };
1642
+ }
1643
+
1644
+ // -------------------------------------------------------------------------
1645
+ // behavior — methods (async welcome here; the getters above carry data)
1646
+
1647
+ static defineCheck(name: string, run: (context: GateContext) => Finding[]): StandardCheck {
1648
+ return { name, enforced: true, run };
1649
+ }
1650
+
1651
+ static finding(check: StandardCheck, unit: SourceUnit, line: number, message: string): Finding {
1652
+ return { check: check.name, file: unit.relativePath, line, message };
1653
+ }
1654
+
1655
+ static componentFinding(check: StandardCheck, component: ComponentUnit, line: number, message: string): Finding {
1656
+ return { check: check.name, file: component.relativePath, line, message };
1657
+ }
1658
+
1659
+ /** Minimal glob: `**` any depth, `*` within a segment, `?` one character. */
1660
+ static globToRegExp(glob: string): RegExp {
1661
+ let pattern = '';
1662
+ for (let index = 0; index < glob.length; index++) {
1663
+ const character = glob[index];
1664
+ if (character === '*') {
1665
+ if (glob[index + 1] === '*') {
1666
+ index++;
1667
+ if (glob[index + 1] === '/') index++;
1668
+ pattern += '(?:.*/)?';
1669
+ } else pattern += '[^/]*';
1670
+ } else if (character === '?') pattern += '[^/]';
1671
+ else pattern += character.replace(/[.+^${}()|[\]\\]/g, '\\$&');
1672
+ }
1673
+ return new RegExp(`^${pattern}$`);
1674
+ }
1675
+
1676
+ static *walk(directory: string): Generator<string> {
1677
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
1678
+ if (this.EXCLUDED_DIRECTORIES.has(entry.name)) continue;
1679
+ const path = join(directory, entry.name);
1680
+ if (entry.isDirectory()) yield* this.walk(path);
1681
+ else if (entry.isFile()) yield path;
1682
+ }
1683
+ }
1684
+
1685
+ static toUnit(cwd: string, path: string): SourceUnit {
1686
+ const text = readFileSync(path, 'utf8');
1687
+ return {
1688
+ path,
1689
+ relativePath: relative(cwd, path).replaceAll('\\', '/'),
1690
+ text,
1691
+ lines: text.split('\n'),
1692
+ ast: ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
1693
+ };
1694
+ }
1695
+
1696
+ static lineOf(unit: SourceUnit, node: ts.Node): number {
1697
+ return unit.ast.getLineAndCharacterOfPosition(node.getStart(unit.ast)).line + 1;
1698
+ }
1699
+
1700
+ static collectTemplateExpressions(nodes: TemplateChildNode[], into: TemplateExpression[]): void {
1701
+ for (const node of nodes) {
1702
+ if (node.type === NodeTypes.INTERPOLATION && node.content.type === NodeTypes.SIMPLE_EXPRESSION) {
1703
+ into.push({ code: node.content.content, line: node.loc.start.line, kind: 'interpolation' });
1704
+ } else if (node.type === NodeTypes.ELEMENT) {
1705
+ const element = node as ElementNode;
1706
+ for (const property of element.props) {
1707
+ if (property.type !== NodeTypes.DIRECTIVE || !property.exp || property.exp.type !== NodeTypes.SIMPLE_EXPRESSION) continue;
1708
+ if (this.TEMPLATE_IGNORED_DIRECTIVES.has(property.name)) continue;
1709
+ let code = property.exp.content;
1710
+ if (property.name === 'for') {
1711
+ const source = /\s+(?:in|of)\s+([\s\S]+)$/.exec(code);
1712
+ if (!source) continue;
1713
+ code = source[1];
1714
+ }
1715
+ into.push({ code, line: property.exp.loc.start.line, kind: property.name });
1716
+ }
1717
+ this.collectTemplateExpressions(element.children, into);
1718
+ } else if (node.type === NodeTypes.IF) {
1719
+ for (const branch of node.branches) {
1720
+ 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);
1722
+ }
1723
+ } else if (node.type === NodeTypes.FOR) {
1724
+ 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);
1726
+ }
1727
+ }
1728
+ }
1729
+
1730
+ static toComponent(cwd: string, path: string): ComponentUnit {
1731
+ const text = readFileSync(path, 'utf8');
1732
+ const { descriptor } = parseSfc(text, { filename: path });
1733
+ const scriptBlock = descriptor.scriptSetup;
1734
+ const scriptLine = scriptBlock ? scriptBlock.loc.start.line : 0;
1735
+ const script: SourceUnit | null = scriptBlock
1736
+ ? {
1737
+ path,
1738
+ relativePath: relative(cwd, path).replaceAll('\\', '/'),
1739
+ text: scriptBlock.content,
1740
+ lines: scriptBlock.content.split('\n'),
1741
+ ast: ts.createSourceFile(path, scriptBlock.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
1742
+ }
1743
+ : null;
1744
+ const expressions: TemplateExpression[] = [];
1745
+ if (descriptor.template) {
1746
+ const templateAst = parseTemplate(descriptor.template.content, { comments: false });
1747
+ this.collectTemplateExpressions(templateAst.children, expressions);
1748
+ const offset = descriptor.template.loc.start.line - 1;
1749
+ for (const expression of expressions) expression.line += offset;
1750
+ }
1751
+ return { path, relativePath: relative(cwd, path).replaceAll('\\', '/'), text, script, scriptLine, expressions };
1752
+ }
1753
+
1754
+ /** A template expression parsed as one TypeScript expression (null when it does not parse). */
1755
+ static parseExpression(code: string): ts.Expression | null {
1756
+ const file = ts.createSourceFile('expression.ts', `(${code});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
1757
+ const statement = file.statements[0];
1758
+ if (!statement || !ts.isExpressionStatement(statement)) return null;
1759
+ let expression: ts.Expression = statement.expression;
1760
+ while (ts.isParenthesizedExpression(expression)) expression = expression.expression;
1761
+ return expression;
1762
+ }
1763
+
1764
+ static componentLine(component: ComponentUnit, node: ts.Node): number {
1765
+ return component.script ? this.lineOf(component.script, node) + component.scriptLine - 1 : 1;
1766
+ }
1767
+
1768
+ /** `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
+ const constructions: { variable: string; namespace: string; node: ts.Node }[] = [];
1771
+ if (!component.script) return constructions;
1772
+ for (const statement of component.script.ast.statements) {
1773
+ if (!ts.isVariableStatement(statement)) continue;
1774
+ for (const declaration of statement.declarationList.declarations) {
1775
+ const initializer = declaration.initializer;
1776
+ if (initializer && ts.isNewExpression(initializer) && ts.isPropertyAccessExpression(initializer.expression) && initializer.expression.name.text === 'Class' && ts.isIdentifier(initializer.expression.expression) && ts.isIdentifier(declaration.name))
1777
+ constructions.push({ variable: declaration.name.text, namespace: initializer.expression.expression.text, node: declaration });
1778
+ }
1779
+ }
1780
+ return constructions;
1781
+ }
1782
+
1783
+ /** Names declared by `defineProps<{ … }>()` / `withDefaults(defineProps<{ … }>(), …)`. */
1784
+ static propNames(component: ComponentUnit): Set<string> {
1785
+ const names = new Set<string>();
1786
+ if (!component.script) return names;
1787
+ this.forEachDescendant(component.script.ast, (node) => {
1788
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== 'defineProps') return;
1789
+ const typeArgument = node.typeArguments?.[0];
1790
+ if (typeArgument && ts.isTypeLiteralNode(typeArgument)) {
1791
+ for (const member of typeArgument.members) if (member.name && ts.isIdentifier(member.name)) names.add(member.name.text);
1792
+ }
1793
+ const argument = node.arguments[0];
1794
+ if (argument && ts.isObjectLiteralExpression(argument)) {
1795
+ for (const property of argument.properties) if (property.name && ts.isIdentifier(property.name)) names.add(property.name.text);
1796
+ }
1797
+ });
1798
+ return names;
1799
+ }
1800
+
1801
+ static classFileOf(unit: SourceUnit): ClassFile | null {
1802
+ const dollarClasses = unit.ast.statements.filter(
1803
+ (statement): statement is ts.ClassDeclaration =>
1804
+ ts.isClassDeclaration(statement) && !!statement.name && statement.name.text.startsWith('$'),
1805
+ );
1806
+ // The file's identity is the class matching the file name; a private
1807
+ // helper class declared first must not usurp it. First class as fallback.
1808
+ const stem = basename(unit.path).replace(/\.ts$/, '');
1809
+ const rawClass = dollarClasses.find((declaration) => declaration.name!.text === `$${stem}`) ?? dollarClasses[0];
1810
+ if (!rawClass?.name) return null;
1811
+ const rawName = rawClass.name.text;
1812
+ const publicName = rawName.slice(1);
1813
+ const namespace =
1814
+ unit.ast.statements.find(
1815
+ (statement): statement is ts.ModuleDeclaration =>
1816
+ ts.isModuleDeclaration(statement) &&
1817
+ ts.isIdentifier(statement.name) &&
1818
+ statement.name.text === publicName,
1819
+ ) ?? null;
1820
+ let anchorInitializer: ts.Expression | null = null;
1821
+ let classInitializer: ts.Expression | null = null;
1822
+ let hasInstanceType = false;
1823
+ if (namespace?.body && ts.isModuleBlock(namespace.body)) {
1824
+ for (const statement of namespace.body.statements) {
1825
+ if (ts.isVariableStatement(statement)) {
1826
+ for (const declaration of statement.declarationList.declarations) {
1827
+ if (!ts.isIdentifier(declaration.name)) continue;
1828
+ if (declaration.name.text === '$Class') anchorInitializer = declaration.initializer ?? null;
1829
+ if (declaration.name.text === 'Class') classInitializer = declaration.initializer ?? null;
1830
+ }
1831
+ }
1832
+ if (ts.isTypeAliasDeclaration(statement) && statement.name.text === 'Instance') hasInstanceType = true;
1833
+ }
1834
+ }
1835
+ const calls = (expression: ts.Expression | null, callee: string) =>
1836
+ !!expression &&
1837
+ ts.isCallExpression(expression) &&
1838
+ ts.isIdentifier(expression.expression) &&
1839
+ expression.expression.text === callee;
1840
+ return {
1841
+ unit,
1842
+ rawClass,
1843
+ rawName,
1844
+ publicName,
1845
+ namespace,
1846
+ anchorInitializer,
1847
+ classInitializer,
1848
+ hasInstanceType,
1849
+ isReactive: calls(classInitializer, 'Reactive'),
1850
+ isStaticAnchored: calls(anchorInitializer, 'Static'),
1851
+ };
1852
+ }
1853
+
1854
+ static classFileByNamespace(context: GateContext, namespace: string): ClassFile | null {
1855
+ for (const unit of context.sources) {
1856
+ const classFile = this.classFileOf(unit);
1857
+ if (classFile?.publicName === namespace) return classFile;
1858
+ }
1859
+ return null;
1860
+ }
1861
+
1862
+ static isStaticMember(member: ts.ClassElement): boolean {
1863
+ return !!(ts.getCombinedModifierFlags(member as ts.Declaration) & ts.ModifierFlags.Static);
1864
+ }
1865
+
1866
+ static isReadonlyMember(member: ts.ClassElement): boolean {
1867
+ return !!(ts.getCombinedModifierFlags(member as ts.Declaration) & ts.ModifierFlags.Readonly);
1868
+ }
1869
+
1870
+ static memberName(member: ts.ClassElement): string {
1871
+ return member.name && (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) ? member.name.text : '';
1872
+ }
1873
+
1874
+ static isFunctionLike(node: ts.Node | undefined): boolean {
1875
+ return !!node && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
1876
+ }
1877
+
1878
+ /** True when the node sits inside a class body declared in script setup. */
1879
+ static isInsideClassBody(node: ts.Node): boolean {
1880
+ for (let current: ts.Node | undefined = node.parent; current; current = current.parent) {
1881
+ if (ts.isClassDeclaration(current) || ts.isClassExpression(current)) return true;
1882
+ }
1883
+ return false;
1884
+ }
1885
+
1886
+ /** True when an arrow does nothing but call one method on one binding —
1887
+ * `() => app.probe()` — the only body a script-setup lifecycle hook may have. */
1888
+ static thinModelDelegation(callback: ts.Expression | ts.Node): boolean {
1889
+ if (!ts.isArrowFunction(callback)) return false;
1890
+ let body: ts.Node | undefined = callback.body;
1891
+ if (ts.isBlock(body)) {
1892
+ if (body.statements.length !== 1) return false;
1893
+ const only = body.statements[0];
1894
+ body = ts.isReturnStatement(only) ? only.expression : ts.isExpressionStatement(only) ? only.expression : undefined;
1895
+ }
1896
+ if (!body) return false;
1897
+ if (ts.isAwaitExpression(body)) body = body.expression;
1898
+ return ts.isCallExpression(body) && ts.isPropertyAccessExpression(body.expression) && ts.isIdentifier(body.expression.expression);
1899
+ }
1900
+
1901
+ /** The single expression a thin closure delegates to, or null when it does more. */
1902
+ static delegateCall(callback: ts.Expression): ts.CallExpression | null {
1903
+ if (!ts.isArrowFunction(callback)) return null;
1904
+ let body: ts.Node | undefined = callback.body;
1905
+ if (ts.isBlock(body)) {
1906
+ if (body.statements.length !== 1) return null;
1907
+ const only = body.statements[0];
1908
+ body = ts.isReturnStatement(only) ? only.expression : ts.isExpressionStatement(only) ? only.expression : undefined;
1909
+ }
1910
+ if (!body) return null;
1911
+ if (ts.isAwaitExpression(body)) body = body.expression;
1912
+ if (!ts.isCallExpression(body)) return null;
1913
+ const callee = body.expression;
1914
+ const isThisMethod =
1915
+ ts.isPropertyAccessExpression(callee) &&
1916
+ callee.expression.kind === ts.SyntaxKind.ThisKeyword;
1917
+ return isThisMethod ? body : null;
1918
+ }
1919
+
1920
+ static refFactoryName(expression: ts.Expression | undefined): string | null {
1921
+ if (!expression || !ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression)) return null;
1922
+ const name = expression.expression.text;
1923
+ return ['ref', 'shallowRef', 'computed', 'toRef'].includes(name) ? name : null;
1924
+ }
1925
+
1926
+ /** Getter names of a class whose body returns a Ref factory call. */
1927
+ static refGetterNames(rawClass: ts.ClassDeclaration): Set<string> {
1928
+ const names = new Set<string>();
1929
+ for (const member of rawClass.members) {
1930
+ if (!ts.isGetAccessorDeclaration(member) || !member.body) continue;
1931
+ const returned = member.body.statements.find(ts.isReturnStatement);
1932
+ if (returned && this.refFactoryName(returned.expression)) names.add(this.memberName(member));
1933
+ }
1934
+ return names;
1935
+ }
1936
+
1937
+ static forEachDescendant(node: ts.Node, visit: (node: ts.Node) => void): void {
1938
+ visit(node);
1939
+ node.forEachChild((child) => this.forEachDescendant(child, visit));
1940
+ }
1941
+
1942
+ static importedBindings(unit: SourceUnit): Set<string> {
1943
+ const names = new Set<string>();
1944
+ for (const statement of unit.ast.statements) {
1945
+ if (!ts.isImportDeclaration(statement) || !statement.importClause) continue;
1946
+ if (statement.importClause.isTypeOnly) continue;
1947
+ const { name, namedBindings } = statement.importClause;
1948
+ if (name) names.add(name.text);
1949
+ if (namedBindings && ts.isNamedImports(namedBindings)) {
1950
+ for (const element of namedBindings.elements) if (!element.isTypeOnly) names.add(element.name.text);
1951
+ }
1952
+ if (namedBindings && ts.isNamespaceImport(namedBindings)) names.add(namedBindings.name.text);
1953
+ }
1954
+ return names;
1955
+ }
1956
+
1957
+ static qualifiedTail(typeNode: ts.TypeNode): { namespace: string; member: string } | null {
1958
+ if (!ts.isTypeReferenceNode(typeNode) || !ts.isQualifiedName(typeNode.typeName)) return null;
1959
+ const left = typeNode.typeName.left;
1960
+ return ts.isIdentifier(left) ? { namespace: left.text, member: typeNode.typeName.right.text } : null;
1961
+ }
1962
+
1963
+ static isInsideFunctionBody(node: ts.Node): boolean {
1964
+ for (let current: ts.Node | undefined = node.parent; current; current = current.parent) {
1965
+ if (ts.isMethodDeclaration(current) || ts.isGetAccessorDeclaration(current) || ts.isSetAccessorDeclaration(current) || ts.isConstructorDeclaration(current) || ts.isArrowFunction(current) || ts.isFunctionExpression(current) || ts.isFunctionDeclaration(current)) return true;
1966
+ }
1967
+ return false;
1968
+ }
1969
+
1970
+ static parseHeader(unit: SourceUnit): GeneratorHeader {
1971
+ const grammar = this.$grammar;
1972
+ const text = unit.text;
1973
+ const header: GeneratorHeader = {
1974
+ present: text.includes(grammar.GENERATOR),
1975
+ firstContent: false,
1976
+ goal: '',
1977
+ formal: '',
1978
+ described: '',
1979
+ orderedRegisters: false,
1980
+ bothRegisters: false,
1981
+ subjects: [],
1982
+ domainClaims: new Map(),
1983
+ domainSymbols: new Set(),
1984
+ impossibilities: new Map(),
1985
+ contractLinks: [],
1986
+ endLine: 0,
1987
+ };
1988
+ if (!header.present) return header;
1989
+ const sentinelIndex = text.indexOf(grammar.GENERATOR);
1990
+ const blockStart = text.lastIndexOf('/*', sentinelIndex);
1991
+ const blockEnd = text.indexOf('*/', sentinelIndex);
1992
+ if (blockStart < 0 || blockEnd < 0) return header;
1993
+ header.firstContent = text.slice(0, blockStart).trim() === '';
1994
+ const block = text.slice(blockStart, blockEnd + 2);
1995
+ header.endLine = text.slice(0, blockEnd + 2).split('\n').length;
1996
+ const describedIndex = block.indexOf(grammar.GENERATOR_DESCRIBED);
1997
+ const generatorIndex = block.indexOf(grammar.GENERATOR);
1998
+ header.bothRegisters = describedIndex >= 0;
1999
+ header.orderedRegisters = describedIndex > generatorIndex;
2000
+ header.formal = block.slice(generatorIndex + grammar.GENERATOR.length, describedIndex >= 0 ? describedIndex : undefined);
2001
+ header.described = describedIndex >= 0 ? block.slice(describedIndex + grammar.GENERATOR_DESCRIBED.length) : '';
2002
+ header.goal = /^\s*\*?\s*Goal:\s*(.+\S)\s*$/m.exec(header.formal)?.[1] ?? '';
2003
+ const formalStartLine = text.slice(0, blockStart + generatorIndex).split('\n').length;
2004
+ header.formal.split('\n').forEach((line, offset) => {
2005
+ const subject = /^\s*\*?\s*Subject:\s*(.+\S)\s*$/.exec(line);
2006
+ if (subject) {
2007
+ for (const path of subject[1].split(/[\s,]+/).filter(Boolean)) header.subjects.push({ path, line: formalStartLine + offset });
2008
+ return;
2009
+ }
2010
+ const domain = grammar.DOMAIN_LINE.exec(line);
2011
+ if (domain) {
2012
+ const symbol = domain[1].trim();
2013
+ const claim = domain[2].trim();
2014
+ header.domainClaims.set(`${symbol} — ${claim}`, { symbol, claim, line: formalStartLine + offset });
2015
+ header.domainSymbols.add(symbol);
2016
+ }
2017
+ const impossible = /^\s*\*?\s*Impossible if true:\s*(.+\S)\s*$/.exec(line);
2018
+ if (impossible) header.impossibilities.set(impossible[1].trim(), formalStartLine + offset);
2019
+ for (const link of line.matchAll(grammar.CONTRACT_LINK)) {
2020
+ header.contractLinks.push({
2021
+ text: link[1],
2022
+ file: link[2],
2023
+ anchor: (link[3] ?? '').slice(1),
2024
+ line: formalStartLine + offset,
2025
+ });
2026
+ }
2027
+ });
2028
+ return header;
2029
+ }
2030
+
2031
+ static parseProofs(unit: SourceUnit, header: GeneratorHeader): ProofAnnotation[] {
2032
+ const grammar = this.$grammar;
2033
+ const proofs: ProofAnnotation[] = [];
2034
+ let pending: ProofAnnotation[] = [];
2035
+ let documentationOpen = false;
2036
+ for (let index = header.endLine; index < unit.lines.length; index++) {
2037
+ const line = unit.lines[index];
2038
+ if (documentationOpen) {
2039
+ if (line.includes('*/')) documentationOpen = false;
2040
+ continue;
2041
+ }
2042
+ const domain = grammar.DOMAIN_LINE.exec(line);
2043
+ if (domain && line.trimStart().startsWith('//')) {
2044
+ pending.push({ type: 'domain', symbol: domain[1].trim(), claim: domain[2].trim(), line: index + 1, bound: false });
2045
+ continue;
2046
+ }
2047
+ const impossible = grammar.IMPOSSIBLE_LINE.exec(line);
2048
+ if (impossible) {
2049
+ pending.push({ type: 'impossible', symbol: impossible[1].trim(), claim: impossible[2].trim(), line: index + 1, bound: false });
2050
+ continue;
2051
+ }
2052
+ const record = grammar.RECORD_LINE.exec(line);
2053
+ if (record && line.trimStart().startsWith('//')) {
2054
+ pending.push({ type: 'record', name: record[1].trim(), contractPath: record[2].trim(), line: index + 1, bound: false });
2055
+ continue;
2056
+ }
2057
+ if (pending.length && /^\s*\/\*\*/.test(line)) {
2058
+ documentationOpen = !line.includes('*/');
2059
+ continue;
2060
+ }
2061
+ if (pending.length && this.TEST_CALL.test(line)) {
2062
+ for (const proof of pending) proof.bound = true;
2063
+ proofs.push(...pending);
2064
+ pending = [];
2065
+ continue;
2066
+ }
2067
+ if (pending.length && line.trim() !== '') {
2068
+ proofs.push(...pending);
2069
+ pending = [];
2070
+ }
2071
+ }
2072
+ proofs.push(...pending);
2073
+ return proofs;
2074
+ }
2075
+
2076
+ static headingSlug(name: string): string {
2077
+ return name
2078
+ .toLowerCase()
2079
+ .replace(/[^\p{L}\p{N}\s-]/gu, '')
2080
+ .trim()
2081
+ .replace(/\s+/g, '-');
2082
+ }
2083
+
2084
+ static contractSlugs(path: string): Set<string> | null {
2085
+ if (!existsSync(path)) return null;
2086
+ const slugs = new Set<string>();
2087
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
2088
+ const heading = /^###\s+(.+\S)\s*$/.exec(line);
2089
+ if (heading) slugs.add(this.headingSlug(heading[1]));
2090
+ }
2091
+ return slugs;
2092
+ }
2093
+
2094
+ static declaredInSource(sourceText: string, symbol: string): boolean {
2095
+ const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2096
+ return new RegExp(`(?:class|function|interface|type|enum|namespace|const|let|var)\\s+${escaped}\\b`).test(
2097
+ sourceText,
2098
+ );
2099
+ }
2100
+
2101
+ static siblingSourcePath(testPath: string): string {
2102
+ return testPath.replace(/\.test\.ts$/, '.ts');
2103
+ }
2104
+
2105
+ /** The skip list is a JSON array of { path, check, reason } — named
2106
+ * fields, no invisible delimiters. Every entry must name a real check
2107
+ * and carry a reason; duplicates are refused. */
2108
+ /** The run's final severity per check: the receiver's `severities`
2109
+ * defaults, overridden by the run's --warn/--off. Unknown names and a
2110
+ * check assigned both warn and off are refused. */
2111
+ static resolveSeverities(options: GateOptions): Map<string, 'warn' | 'off'> {
2112
+ const known = this.checkNames;
2113
+ const resolved = new Map<string, 'warn' | 'off'>();
2114
+ for (const [name, severity] of Object.entries(this.severities)) {
2115
+ if (!known.has(name)) throw new CheckStandard.GateUsageError(`severities: unknown check name "${name}" — --list names every check`);
2116
+ if (severity !== 'error') resolved.set(name, severity);
2117
+ }
2118
+ for (const [flag, severity] of [['--warn', 'warn'], ['--off', 'off']] as const) {
2119
+ const names = flag === '--warn' ? options.warnChecks : options.offChecks;
2120
+ for (const name of names ?? []) {
2121
+ if (!known.has(name)) throw new CheckStandard.GateUsageError(`${flag}: unknown check name "${name}" — --list names every check`);
2122
+ resolved.set(name, severity);
2123
+ }
2124
+ }
2125
+ for (const name of options.warnChecks ?? []) {
2126
+ if ((options.offChecks ?? []).includes(name)) throw new CheckStandard.GateUsageError(`"${name}" is both warn and off — pick one severity`);
2127
+ }
2128
+ return resolved;
2129
+ }
2130
+
2131
+ static readSkipList(cwd: string, path: string): SkipRow[] {
2132
+ const absolute = isAbsolute(path) ? path : resolve(cwd, path);
2133
+ if (!existsSync(absolute)) throw new CheckStandard.GateUsageError(`skip-list not found: ${path}`);
2134
+ let parsed: unknown;
2135
+ try {
2136
+ parsed = JSON.parse(readFileSync(absolute, 'utf8'));
2137
+ } catch (error) {
2138
+ 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
+ }
2140
+ 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[] = [];
2142
+ const seen = new Set<string>();
2143
+ const knownNames = this.checkNames;
2144
+ parsed.forEach((entry, index) => {
2145
+ const label = `skip-list ${path} entry ${index + 1}`;
2146
+ if (typeof entry !== 'object' || entry === null) throw new CheckStandard.GateUsageError(`${label}: an entry is an object — { path, check, reason }`);
2147
+ const { path: rowPath, check: checkName, reason } = entry as Record<string, unknown>;
2148
+ for (const [field, value] of [['path', rowPath], ['check', checkName], ['reason', reason]] as const) {
2149
+ if (typeof value !== 'string' || !value.trim()) throw new CheckStandard.GateUsageError(`${label}: "${field}" is a non-empty string — { path, check, reason }`);
2150
+ }
2151
+ if (!knownNames.has(checkName as string)) throw new CheckStandard.GateUsageError(`${label}: unknown check name "${checkName}" — --list names every check`);
2152
+ const key = `${rowPath}\u0000${checkName}`;
2153
+ if (seen.has(key)) throw new CheckStandard.GateUsageError(`${label}: duplicate skip for ${rowPath} / ${checkName}`);
2154
+ seen.add(key);
2155
+ rows.push({ path: (rowPath as string).trim().replaceAll('\\', '/'), check: (checkName as string).trim(), reason: (reason as string).trim(), line: index + 1 });
2156
+ });
2157
+ return rows;
2158
+ }
2159
+
2160
+ /** Discover, check, apply the skip-list. Throws GateUsageError on a refused population. */
2161
+ static run(options: GateOptions): GateResult {
2162
+ const cwd = resolve(options.cwd);
2163
+ if (!options.sourceRoots.length) throw new CheckStandard.GateUsageError('at least one --source-root is required');
2164
+ const testMatchers = options.testGlobs.map((glob) => ({ glob, regexp: this.globToRegExp(glob) }));
2165
+ const sources: SourceUnit[] = [];
2166
+ const tests: SourceUnit[] = [];
2167
+ const components: ComponentUnit[] = [];
2168
+ const isTest = (relativePath: string) => testMatchers.some((matcher) => matcher.regexp.test(relativePath));
2169
+ for (const root of options.sourceRoots) {
2170
+ const absoluteRoot = isAbsolute(root) ? root : resolve(cwd, root);
2171
+ if (!existsSync(absoluteRoot) || !statSync(absoluteRoot).isDirectory()) throw new CheckStandard.GateUsageError(`source root is not a directory: ${root}`);
2172
+ for (const path of this.walk(absoluteRoot)) {
2173
+ if (path.endsWith('.vue')) {
2174
+ components.push(this.toComponent(cwd, path));
2175
+ continue;
2176
+ }
2177
+ if (!path.endsWith('.ts') || path.endsWith('.d.ts')) continue;
2178
+ const relativePath = relative(cwd, path).replaceAll('\\', '/');
2179
+ if (isTest(relativePath) || /\.(?:test|spec)\.ts$/.test(path)) {
2180
+ if (isTest(relativePath)) tests.push(this.toUnit(cwd, path));
2181
+ continue;
2182
+ }
2183
+ sources.push(this.toUnit(cwd, path));
2184
+ }
2185
+ }
2186
+ for (const matcher of testMatchers) {
2187
+ const base = matcher.glob.split(/[*?[]/)[0].replace(/\/[^/]*$/, '') || '.';
2188
+ const absoluteBase = resolve(cwd, base);
2189
+ if (!existsSync(absoluteBase)) continue;
2190
+ for (const path of this.walk(absoluteBase)) {
2191
+ const relativePath = relative(cwd, path).replaceAll('\\', '/');
2192
+ if (matcher.regexp.test(relativePath) && !tests.some((unit) => unit.path === path)) tests.push(this.toUnit(cwd, path));
2193
+ }
2194
+ }
2195
+ if (!sources.length) throw new CheckStandard.GateUsageError(`no source files discovered under ${options.sourceRoots.join(', ')} — refusing to pass over nothing`);
2196
+ for (const matcher of testMatchers) {
2197
+ if (!tests.some((unit) => matcher.regexp.test(unit.relativePath))) throw new CheckStandard.GateUsageError(`test glob matches no file: ${matcher.glob}`);
2198
+ }
2199
+ const skips = options.skipListPath ? this.readSkipList(cwd, options.skipListPath) : [];
2200
+ const severities = this.resolveSeverities(options);
2201
+
2202
+ const context: GateContext = {
2203
+ cwd,
2204
+ sourceRoots: options.sourceRoots.map((root) => (isAbsolute(root) ? root : resolve(cwd, root))),
2205
+ sources,
2206
+ tests,
2207
+ components,
2208
+ testGlobs: options.testGlobs,
2209
+ staticImplementation: options.staticImplementation ?? null,
2210
+ };
2211
+ const raw: Finding[] = [];
2212
+ for (const entry of this.checks) {
2213
+ if (!entry.enforced || severities.get(entry.name) === 'off') continue;
2214
+ raw.push(...entry.run(context));
2215
+ }
2216
+
2217
+ const findings: Finding[] = [];
2218
+ const warnings: Finding[] = [];
2219
+ const suppressed: Finding[] = [];
2220
+ const used = new Set<SkipRow>();
2221
+ for (const item of raw) {
2222
+ const row = skips.find((skip) => skip.check === item.check && skip.path === item.file);
2223
+ if (row) {
2224
+ used.add(row);
2225
+ suppressed.push(item);
2226
+ } else if (severities.get(item.check) === 'warn') warnings.push(item);
2227
+ else findings.push(item);
2228
+ }
2229
+ for (const row of skips) {
2230
+ if (!used.has(row)) {
2231
+ const message = existsSync(resolve(cwd, row.path))
2232
+ ? `stale skip: "${row.check}" no longer fires on ${row.path} — remove the row`
2233
+ : `stale skip: ${row.path} does not exist — remove the row`;
2234
+ findings.push({ check: this.the_population_and_skip_list_are_exact.name, file: options.skipListPath ?? 'skip-list', line: row.line, message });
2235
+ }
2236
+ }
2237
+ const byPlace = (first: Finding, second: Finding) => first.file.localeCompare(second.file) || first.line - second.line;
2238
+ findings.sort(byPlace);
2239
+ warnings.sort(byPlace);
2240
+ return {
2241
+ findings,
2242
+ warnings,
2243
+ suppressed,
2244
+ sources: sources.map((unit) => unit.relativePath),
2245
+ tests: tests.map((unit) => unit.relativePath),
2246
+ unenforced: this.checks.filter((entry) => !entry.enforced).map((entry) => entry.name),
2247
+ off: this.checks.filter((entry) => severities.get(entry.name) === 'off').map((entry) => entry.name),
2248
+ };
2249
+ }
2250
+
2251
+ /** Run the receiver's whole constitution: every check's red and green
2252
+ * arms through run(), refusing a manifest whose check lacks them.
2253
+ * `only` isolates one check by name — its arms and nothing else. */
2254
+ static prove(options?: { completenessOnly?: boolean; only?: string }): ProveReport {
2255
+ const problems: string[] = [];
2256
+ const ran = { red: 0, green: 0 };
2257
+ const proofs = this.proofs;
2258
+ const selected = options?.only ? this.checks.filter((entry) => entry.name === options.only) : this.checks;
2259
+ if (options?.only && !selected.length) problems.push(`prove: unknown check name "${options.only}" — --list names every check`);
2260
+ if (!options?.only) {
2261
+ for (const name of Object.keys(proofs)) {
2262
+ if (!this.checks.some((entry) => entry.name === name)) problems.push(`proof without a manifest check: ${name}`);
2263
+ }
2264
+ }
2265
+ for (const check of selected) {
2266
+ const asGetter = (this as unknown as Record<string, StandardCheck | undefined>)[check.name];
2267
+ 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
+ const proof = proofs[check.name];
2269
+ if (!proof) {
2270
+ problems.push(`${check.name}: no constitution entry — a manifest check carries its claim, impossibility, and both proof arms`);
2271
+ continue;
2272
+ }
2273
+ if (!/^If .+, then .+/.test(proof.claim)) problems.push(`${check.name}: the claim is not an if-then`);
2274
+ if (!proof.impossibility) problems.push(`${check.name}: no impossibility`);
2275
+ if (!proof.red.length) problems.push(`${check.name}: no red arm — a check that cannot fail proves nothing`);
2276
+ if (!proof.green.length) problems.push(`${check.name}: no green arm — silence on the conforming form is half the proof`);
2277
+ if (options?.completenessOnly) continue;
2278
+ for (const [kind, arms] of [['red', proof.red], ['green', proof.green]] as const) {
2279
+ for (const arm of arms) {
2280
+ const checkout = mkdtempSync(join(tmpdir(), 'ivue-gate-proof-'));
2281
+ try {
2282
+ writeFileSync(join(checkout, 'package.json'), JSON.stringify(arm.manifest ?? { name: 'consumer', dependencies: { ivue: '*' } }));
2283
+ for (const [path, text] of Object.entries(arm.files)) {
2284
+ mkdirSync(dirname(join(checkout, path)), { recursive: true });
2285
+ writeFileSync(join(checkout, path), text);
2286
+ }
2287
+ const hasTests = Object.keys(arm.files).some((path) => path.endsWith('.test.ts'));
2288
+ const gateOptions: GateOptions = {
2289
+ cwd: checkout,
2290
+ sourceRoots: ['src'],
2291
+ testGlobs: hasTests ? ['src/**/*.test.ts'] : [],
2292
+ staticImplementation: Static,
2293
+ ...arm.options,
2294
+ };
2295
+ if (arm.options && 'staticImplementation' in arm.options) gateOptions.staticImplementation = arm.options.staticImplementation ?? null;
2296
+ // Proofs establish what a check DETECTS; severity is only how a
2297
+ // detection is REPORTED. So unless this arm explicitly tests
2298
+ // severity (its options carry warnChecks/offChecks), a receiver's
2299
+ // own severities getter must not bend the arm: the check's
2300
+ // warnings fold back into its findings.
2301
+ const armTestsSeverity = !!arm.options && ('warnChecks' in arm.options || 'offChecks' in arm.options);
2302
+ let findings: Finding[] = [];
2303
+ let warnings: Finding[] = [];
2304
+ let thrown: Error | null = null;
2305
+ try {
2306
+ const result = this.run(gateOptions);
2307
+ findings = result.findings.filter((item) => item.check === check.name);
2308
+ // warnings stay unfiltered: a severity arm demotes ANOTHER
2309
+ // check, so its warning carries that check's name
2310
+ warnings = result.warnings;
2311
+ if (!armTestsSeverity) {
2312
+ findings = [...findings, ...result.warnings.filter((item) => item.check === check.name)];
2313
+ warnings = [];
2314
+ }
2315
+ } catch (error) {
2316
+ thrown = error as Error;
2317
+ }
2318
+ if (arm.expectThrows) {
2319
+ if (!thrown || !arm.expectThrows.test(thrown.message)) problems.push(`${check.name} ${kind} arm: expected a refusal matching ${arm.expectThrows} — got ${thrown ? thrown.message : `${findings.length} finding(s)`}`);
2320
+ } else if (thrown) {
2321
+ problems.push(`${check.name} ${kind} arm: the gate threw: ${thrown.message}`);
2322
+ } else {
2323
+ for (const expected of arm.expectWarnings ?? []) {
2324
+ const pattern = typeof expected === 'string' ? new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : expected;
2325
+ if (!warnings.some((item) => pattern.test(item.message))) problems.push(`${check.name} ${kind} arm: no warning matches ${pattern}`);
2326
+ }
2327
+ if (!arm.expectWarnings?.length && warnings.length) problems.push(`${check.name} ${kind} arm: unexpected warning(s): ${warnings[0].message}`);
2328
+ if (kind === 'red') {
2329
+ for (const expected of arm.expectFindings ?? []) {
2330
+ const pattern = typeof expected === 'string' ? new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : expected;
2331
+ if (!findings.some((item) => pattern.test(item.message))) problems.push(`${check.name} red arm: no finding matches ${pattern}`);
2332
+ }
2333
+ if (!arm.expectFindings?.length && !findings.length) problems.push(`${check.name} red arm: the planted defect produced no finding`);
2334
+ if (arm.expectCount !== undefined && findings.length !== arm.expectCount) problems.push(`${check.name} red arm: expected ${arm.expectCount} finding(s), got ${findings.length}`);
2335
+ } else if (findings.length) {
2336
+ problems.push(`${check.name} green arm: the conforming form produced ${findings.length} finding(s): ${findings[0].message}`);
2337
+ }
2338
+ }
2339
+ ran[kind === 'red' ? 'red' : 'green']++;
2340
+ } finally {
2341
+ rmSync(checkout, { recursive: true, force: true });
2342
+ }
2343
+ }
2344
+ }
2345
+ }
2346
+ return { problems, ran };
2347
+ }
2348
+
2349
+ static async main(argv: string[], cwd = process.cwd()): Promise<number> {
2350
+ const HELP = `ivue Standard gate — checks class sources and test files against skills/ivue/SKILL.md
2351
+
2352
+ usage:
2353
+ ivue-standards-check --source-root <dir> [--source-root <dir>…]
2354
+ --test-glob '<glob>' [--test-glob '<glob>'…]
2355
+ [--skip-list <path>] a JSON array of { path, check, reason }
2356
+ ivue-standards-check --list print every check name and severity
2357
+ ivue-standards-check --prove ['<check name>'] run the gate's own constitution
2358
+ (name it to isolate one check's arms)
2359
+
2360
+ Exit: 0 clean · 1 findings · 2 usage (zero files, unmatched glob, unknown check
2361
+ name, duplicate or stale skip row). Paths in findings are relative to the cwd.`;
2362
+ const sourceRoots: string[] = [];
2363
+ const testGlobs: string[] = [];
2364
+ let skipListPath: string | undefined;
2365
+ for (let index = 0; index < argv.length; index++) {
2366
+ const argument = argv[index];
2367
+ const value = () => {
2368
+ const next = argv[++index];
2369
+ if (next === undefined) throw new CheckStandard.GateUsageError(`${argument} needs a value`);
2370
+ return next;
2371
+ };
2372
+ try {
2373
+ if (argument === '--help' || argument === '-h') {
2374
+ console.log(HELP);
2375
+ return 0;
2376
+ } else if (argument === '--list') {
2377
+ if (argv.length > 1) throw new CheckStandard.GateUsageError('--list takes no other arguments');
2378
+ for (const entry of this.checks) {
2379
+ const severity = this.severities[entry.name];
2380
+ const label = !entry.enforced ? 'not yet' : (severity ?? 'error');
2381
+ console.log(`${label.padEnd(11)} ${entry.name}`);
2382
+ }
2383
+ return 0;
2384
+ } else if (argument === '--prove') {
2385
+ const next = argv[index + 1];
2386
+ const only = next !== undefined && !next.startsWith('--') ? argv[++index] : undefined;
2387
+ if (sourceRoots.length || testGlobs.length || skipListPath || index + 1 < argv.length)
2388
+ throw new CheckStandard.GateUsageError('--prove runs the constitution over its own fixture checkouts — it does not combine with --source-root, --test-glob, or --skip-list');
2389
+ const report = this.prove(only ? { only } : undefined);
2390
+ for (const problem of report.problems) console.error(problem);
2391
+ console.log(`ivue-standards-check --prove${only ? ` "${only}"` : ''}: ${report.ran.red} red arm(s), ${report.ran.green} green arm(s), ${report.problems.length} problem(s)`);
2392
+ return report.problems.length ? 1 : 0;
2393
+ } else if (argument === '--source-root') sourceRoots.push(value());
2394
+ else if (argument === '--test-glob') testGlobs.push(value());
2395
+ else if (argument === '--skip-list') skipListPath = value();
2396
+ else throw new CheckStandard.GateUsageError(`unknown argument: ${argument}`);
2397
+ } catch (error) {
2398
+ console.error(`ivue-standards-check: ${(error as Error).message}`);
2399
+ return 2;
2400
+ }
2401
+ }
2402
+ let result: GateResult;
2403
+ try {
2404
+ result = this.run({ cwd, sourceRoots, testGlobs, skipListPath, staticImplementation: Static });
2405
+ } catch (error) {
2406
+ if (error instanceof CheckStandard.GateUsageError) {
2407
+ console.error(`ivue-standards-check: ${error.message}`);
2408
+ return 2;
2409
+ }
2410
+ throw error;
2411
+ }
2412
+ for (const item of result.findings) console.error(`${item.file}:${item.line}: [${item.check}] ${item.message}`);
2413
+ for (const item of result.warnings) console.error(`warn: ${item.file}:${item.line}: [${item.check}] ${item.message}`);
2414
+ console.log(
2415
+ `ivue-standards-check: ${result.sources.length} source file(s), ${result.tests.length} test file(s), ` +
2416
+ `${result.findings.length} finding(s), ${result.warnings.length} warning(s), ${result.suppressed.length} suppressed by skip-list`,
2417
+ );
2418
+ if (result.off.length) console.log(`off by config (${result.off.length}): ${result.off.join(' · ')}`);
2419
+ if (result.unenforced.length) console.log(`not enforced yet (${result.unenforced.length}): ${result.unenforced.join(' · ')}`);
2420
+ return result.findings.length ? 1 : 0;
2421
+ }
2422
+ }
2423
+
2424
+ export namespace CheckStandard {
2425
+ export const $Class = Static($CheckStandard);
2426
+ export let Class = $Class;
2427
+
2428
+ export class GateUsageError extends Error {}
2429
+
2430
+ // Entry detection that survives every runner AND subclass gates. The
2431
+ // flags in argv say a gate CLI was invoked (`vite-node <gate>.ts -- …`
2432
+ // leaves them there; a test runner importing this module does not; the
2433
+ // runner strips the script path, so argv cannot say WHICH gate). Module
2434
+ // evaluation order says which: imports evaluate before the entry, so the
2435
+ // entry's bootstrap registers LAST — a house gate importing this file
2436
+ // supersedes this registration before the deferred main runs.
2437
+ let selectedCliGate: { main(argv: string[]): Promise<number> } | null = null;
2438
+
2439
+ export function bootstrapCli(gate: { main(argv: string[]): Promise<number> }): void {
2440
+ const cliArguments = process.argv.slice(2);
2441
+ const invokedAsCli =
2442
+ !process.env.VITEST &&
2443
+ cliArguments.some((argument) => ['--source-root', '--test-glob', '--skip-list', '--list', '--prove', '--help', '-h'].includes(argument));
2444
+ if (!invokedAsCli) return;
2445
+ const isFirstRegistration = selectedCliGate === null;
2446
+ selectedCliGate = gate;
2447
+ // beforeExit, not setImmediate: vite-node evaluates modules with async
2448
+ // gaps, so a macrotask scheduled during the FIRST module's eval can fire
2449
+ // before later modules register. beforeExit only fires once the whole
2450
+ // graph has evaluated and the loop drained — the last registrant has won.
2451
+ if (isFirstRegistration)
2452
+ process.once('beforeExit', () => {
2453
+ // a live timer holds the loop open while main runs: Bun exits before
2454
+ // draining microtasks scheduled from a beforeExit handler
2455
+ const keepAlive = setInterval(() => {}, 1000);
2456
+ void selectedCliGate!.main(cliArguments).then((code) => {
2457
+ clearInterval(keepAlive);
2458
+ process.exit(code);
2459
+ });
2460
+ });
2461
+ }
2462
+
2463
+ bootstrapCli(Class);
2464
+ }