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