ast-mcp-server 0.15.1 → 0.17.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/CHANGELOG.md +48 -1
- package/README.md +11 -4
- package/cordis.patch.yml +1 -1
- package/dist/angular-template-typecheck-entry.js +19 -0
- package/dist/services/angular-decorators.js +218 -0
- package/dist/services/angular-package-resolution.js +62 -0
- package/dist/services/angular-template-bindings.js +111 -0
- package/dist/services/angular-template-rename.js +119 -0
- package/dist/services/angular-template-scope.js +163 -0
- package/dist/services/angular-template-typecheck-core.js +139 -0
- package/dist/services/angular-template-typecheck-protocol.js +75 -0
- package/dist/services/angular-template-typecheck.js +262 -0
- package/dist/services/angular-templates.js +273 -103
- package/dist/services/h03-timeout-fixture.js +2 -1
- package/dist/services/operation-block-policy.js +19 -0
- package/dist/services/operation-plan-file.js +38 -4
- package/dist/services/operations.js +109 -22
- package/dist/services/project-operation-scheduler.js +4 -0
- package/dist/tools/find_references.js +2 -1
- package/dist/tools/operation-schema.js +43 -0
- package/dist/tools/rename_symbol.js +9 -4
- package/package.json +9 -4
- package/skills/structural-code-editing/SKILL.md +11 -5
- package/skills/structural-code-editing/references/operations.md +4 -2
- package/skills/structural-code-editing/releases.json +113 -7
|
@@ -1,48 +1,24 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { readFile, stat } from "node:fs/promises";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { Node, SyntaxKind } from "ts-morph";
|
|
8
|
+
import { angularCoreMajor, componentDecorator, isCoreDecorator, isDirectiveClass, isMetadataPublic, isPipeClass, isSelectorPublic, literalString, bindingNames, metadataBindingNames, } from "./angular-decorators.js";
|
|
9
|
+
import { extractConsumerUsages, mayMatch, } from "./angular-template-bindings.js";
|
|
10
|
+
import { createScopeResolver, MAX_SCOPE_VISITS, ScopeWorkExhausted, } from "./angular-template-scope.js";
|
|
11
|
+
import { isSupportedVersion, isUnderAncestorNodeModules, isWithin, resolveFrom, } from "./angular-package-resolution.js";
|
|
8
12
|
import { assertSafeOperationFile } from "./safe-file.js";
|
|
9
13
|
const MAX_TEMPLATE_BYTES = 512 * 1024;
|
|
10
14
|
const MAX_TEMPLATE_NODES = 100_000;
|
|
11
15
|
const CHECKPOINT_EVERY_NODES = 1000;
|
|
12
|
-
const MIN_COMPILER_MAJOR = 18;
|
|
13
|
-
const MAX_COMPILER_MAJOR = 22;
|
|
14
16
|
const TWO_WAY_BINDING = 5; // BindingType.TwoWay, stable across supported majors
|
|
15
17
|
const TWO_WAY_EVENT = 2; // ParsedEventType.TwoWay, stable across supported majors
|
|
18
|
+
const SCAN_LIMITS = { templates: 2000, bytes: 32 * 1024 * 1024, nodes: 1_000_000 };
|
|
16
19
|
const NOT_APPLICABLE = { coverage: { status: "not_applicable" }, usages: [] };
|
|
17
20
|
const ASSIGNMENT_OPERATION = /^(?:=|(?:\*\*|&&|\|\||\?\?|<<|>>>|>>|[+\-*/%&|^])=)$/u;
|
|
18
21
|
const compilerCache = new Map();
|
|
19
|
-
function resolveFrom(tsConfigFilePath, request) {
|
|
20
|
-
try {
|
|
21
|
-
const resolved = createRequire(tsConfigFilePath).resolve(request);
|
|
22
|
-
return existsSync(resolved) ? resolved : undefined;
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
return undefined;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
function isWithin(parent, child) {
|
|
29
|
-
const relative = path.relative(parent, child);
|
|
30
|
-
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
31
|
-
}
|
|
32
|
-
/** True when `packageDir` sits in a node_modules directory owned by `projectDir` or an ancestor. */
|
|
33
|
-
function isUnderAncestorNodeModules(packageDir, projectDir) {
|
|
34
|
-
const segments = packageDir.split(path.sep);
|
|
35
|
-
return segments.some((segment, index) => segment === "node_modules" &&
|
|
36
|
-
isWithin(segments.slice(0, index).join(path.sep) || path.sep, projectDir));
|
|
37
|
-
}
|
|
38
|
-
function isSupportedVersion(version) {
|
|
39
|
-
const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+.*)?$/u.exec(String(version));
|
|
40
|
-
if (!match)
|
|
41
|
-
return false;
|
|
42
|
-
const [major, minor, patch] = [match[1], match[2], match[3]].map(Number);
|
|
43
|
-
const belowFloor = major === MIN_COMPILER_MAJOR && minor === 0 && patch === 0 && !!match[4];
|
|
44
|
-
return major >= MIN_COMPILER_MAJOR && major <= MAX_COMPILER_MAJOR && !belowFloor;
|
|
45
|
-
}
|
|
46
22
|
async function loadCompiler(tsConfigFilePath) {
|
|
47
23
|
const unsupported = { reason: "compiler_unsupported" };
|
|
48
24
|
const manifestPath = resolveFrom(tsConfigFilePath, "@angular/compiler/package.json");
|
|
@@ -92,7 +68,12 @@ async function loadCompiler(tsConfigFilePath) {
|
|
|
92
68
|
compiler.PropertyRead,
|
|
93
69
|
compiler.Call,
|
|
94
70
|
].every((member) => typeof member === "function");
|
|
95
|
-
|
|
71
|
+
const selectors = [
|
|
72
|
+
compiler.CssSelector?.parse,
|
|
73
|
+
compiler.SelectorMatcher,
|
|
74
|
+
compiler.createCssSelectorFromNode,
|
|
75
|
+
].every((member) => typeof member === "function");
|
|
76
|
+
return complete ? { compiler, selectors } : unsupported;
|
|
96
77
|
}
|
|
97
78
|
catch {
|
|
98
79
|
return unsupported;
|
|
@@ -101,29 +82,6 @@ async function loadCompiler(tsConfigFilePath) {
|
|
|
101
82
|
function hasAngularCore(context) {
|
|
102
83
|
return resolveFrom(context.tsConfigFilePath, "@angular/core/package.json") !== undefined;
|
|
103
84
|
}
|
|
104
|
-
function isComponentDecorator(decorator) {
|
|
105
|
-
const callee = decorator.getCallExpression()?.getExpression();
|
|
106
|
-
if (!callee)
|
|
107
|
-
return false;
|
|
108
|
-
return decorator
|
|
109
|
-
.getSourceFile()
|
|
110
|
-
.getImportDeclarations()
|
|
111
|
-
.filter((declaration) => declaration.getModuleSpecifierValue() === "@angular/core")
|
|
112
|
-
.some((declaration) => {
|
|
113
|
-
const namespace = declaration.getNamespaceImport();
|
|
114
|
-
if (Node.isPropertyAccessExpression(callee)) {
|
|
115
|
-
return (callee.getName() === "Component" &&
|
|
116
|
-
callee.getExpression().getText() === namespace?.getText());
|
|
117
|
-
}
|
|
118
|
-
return declaration
|
|
119
|
-
.getNamedImports()
|
|
120
|
-
.some((specifier) => specifier.getName() === "Component" &&
|
|
121
|
-
(specifier.getAliasNode()?.getText() ?? "Component") === callee.getText());
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
function componentDecorator(cls) {
|
|
125
|
-
return cls.getDecorators().find(isComponentDecorator);
|
|
126
|
-
}
|
|
127
85
|
/** Finds the template of a component class from literal metadata only. */
|
|
128
86
|
function locateTemplate(cls) {
|
|
129
87
|
const unresolved = { reason: "template_unresolved" };
|
|
@@ -201,7 +159,7 @@ export function listTemplateFiles(context) {
|
|
|
201
159
|
}
|
|
202
160
|
return [...files].sort();
|
|
203
161
|
}
|
|
204
|
-
function memberOwner(node) {
|
|
162
|
+
export function memberOwner(node) {
|
|
205
163
|
let owner;
|
|
206
164
|
if (Node.isParameterDeclaration(node)) {
|
|
207
165
|
owner = node.isParameterProperty() ? node.getParent()?.getParent() : undefined;
|
|
@@ -232,8 +190,12 @@ function hasDerivedComponent(context, cls) {
|
|
|
232
190
|
}
|
|
233
191
|
class WorkExhausted extends Error {
|
|
234
192
|
}
|
|
193
|
+
/** A pipe name span that does not slice to the name: the usage cannot be edited exactly. */
|
|
194
|
+
class SpanDrift extends Error {
|
|
195
|
+
}
|
|
235
196
|
/** Walks parsed template nodes with a scope stack so template-local names shadow members. */
|
|
236
|
-
function extractUsages(compiler, nodes,
|
|
197
|
+
function extractUsages(compiler, nodes, query, rc, onNode = () => { }) {
|
|
198
|
+
const memberName = "member" in query ? query.member : undefined;
|
|
237
199
|
const usages = new Map();
|
|
238
200
|
const scopes = [];
|
|
239
201
|
let visited = 0;
|
|
@@ -242,6 +204,7 @@ function extractUsages(compiler, nodes, memberName, rc) {
|
|
|
242
204
|
throw new WorkExhausted();
|
|
243
205
|
if (visited % CHECKPOINT_EVERY_NODES === 0)
|
|
244
206
|
rc.checkpoint();
|
|
207
|
+
onNode();
|
|
245
208
|
};
|
|
246
209
|
const isLocal = (name) => scopes.some((scope) => scope.has(name));
|
|
247
210
|
const record = (ast, kind) => {
|
|
@@ -251,13 +214,15 @@ function extractUsages(compiler, nodes, memberName, rc) {
|
|
|
251
214
|
start: ast.nameSpan.start,
|
|
252
215
|
end: ast.nameSpan.end,
|
|
253
216
|
kind: merged,
|
|
217
|
+
role: "own",
|
|
254
218
|
localsInScope: [...new Set(scopes.flatMap((scope) => [...scope]))].sort(),
|
|
255
219
|
});
|
|
256
220
|
};
|
|
257
221
|
// Explicit `this.` bypasses template locals; ThisReceiver only extends ImplicitReceiver in some majors.
|
|
258
222
|
const isMemberReceiver = (receiver) => receiver instanceof compiler.ThisReceiver ||
|
|
259
223
|
(receiver instanceof compiler.ImplicitReceiver && !isLocal(memberName));
|
|
260
|
-
const isMember = (ast) =>
|
|
224
|
+
const isMember = (ast) => memberName !== undefined &&
|
|
225
|
+
ast instanceof compiler.PropertyRead &&
|
|
261
226
|
ast.name === memberName &&
|
|
262
227
|
isMemberReceiver(ast.receiver);
|
|
263
228
|
class Expressions extends compiler.RecursiveAstVisitor {
|
|
@@ -287,6 +252,15 @@ function extractUsages(compiler, nodes, memberName, rc) {
|
|
|
287
252
|
record(ast.left, "TemplatePropertyWrite");
|
|
288
253
|
this.visit(ast.right, context);
|
|
289
254
|
}
|
|
255
|
+
visitPipe(ast, context) {
|
|
256
|
+
if ("pipe" in query && ast.name === query.pipe) {
|
|
257
|
+
if (query.text.slice(ast.nameSpan.start, ast.nameSpan.end) !== query.pipe) {
|
|
258
|
+
throw new SpanDrift();
|
|
259
|
+
}
|
|
260
|
+
record(ast, "TemplatePipeName");
|
|
261
|
+
}
|
|
262
|
+
super.visitPipe(ast, context);
|
|
263
|
+
}
|
|
290
264
|
visitPropertyWrite(ast, context) {
|
|
291
265
|
if (ast.name === memberName && isMemberReceiver(ast.receiver)) {
|
|
292
266
|
record(ast, "TemplatePropertyWrite");
|
|
@@ -321,9 +295,12 @@ function extractUsages(compiler, nodes, memberName, rc) {
|
|
|
321
295
|
return collector.names;
|
|
322
296
|
};
|
|
323
297
|
class Templates extends compiler.TmplAstRecursiveVisitor {
|
|
298
|
+
// v18/v19 `tmplAstVisitAll` runs `visitor.visit(node) || node.visit(visitor)`; returning
|
|
299
|
+
// true marks the node handled so each subtree is walked once instead of twice per level.
|
|
324
300
|
visit(node) {
|
|
325
301
|
tick();
|
|
326
302
|
node.visit(this);
|
|
303
|
+
return true;
|
|
327
304
|
}
|
|
328
305
|
view(locals, children, before) {
|
|
329
306
|
const names = [...locals, ...referencesOf(children)].filter((name) => !!name);
|
|
@@ -429,81 +406,274 @@ function decodeTemplate(bytes) {
|
|
|
429
406
|
return undefined;
|
|
430
407
|
}
|
|
431
408
|
}
|
|
432
|
-
/**
|
|
433
|
-
|
|
434
|
-
rc.checkpoint();
|
|
435
|
-
const owner = memberOwner(node);
|
|
436
|
-
if (!owner || !hasAngularCore(context))
|
|
437
|
-
return NOT_APPLICABLE;
|
|
438
|
-
const isComponent = componentDecorator(owner.cls) !== undefined;
|
|
439
|
-
const derived = hasDerivedComponent(context, owner.cls);
|
|
440
|
-
if (!isComponent && !derived)
|
|
441
|
-
return NOT_APPLICABLE;
|
|
442
|
-
const fail = (status, reason) => ({
|
|
443
|
-
coverage: { status, reason, templates: 0 },
|
|
444
|
-
usages: [],
|
|
445
|
-
});
|
|
446
|
-
if (derived)
|
|
447
|
-
return fail("unsupported", "inherited_member");
|
|
448
|
-
const loaded = await loadCompiler(context.tsConfigFilePath);
|
|
449
|
-
if ("reason" in loaded)
|
|
450
|
-
return fail("unfinished", loaded.reason);
|
|
451
|
-
const located = locateTemplate(owner.cls);
|
|
452
|
-
if ("reason" in located)
|
|
453
|
-
return fail("unfinished", located.reason);
|
|
409
|
+
/** Reads a located template within the per-template size cap. */
|
|
410
|
+
async function readTemplate(context, located) {
|
|
454
411
|
let text;
|
|
455
|
-
let offset = 0;
|
|
456
412
|
let bytes;
|
|
457
413
|
if (located.container === "inline") {
|
|
458
|
-
|
|
414
|
+
text = located.text;
|
|
459
415
|
bytes = Buffer.from(text, "utf8");
|
|
460
416
|
}
|
|
461
417
|
else {
|
|
462
418
|
const unsafe = checkTemplatePath(context.projectRoot, located.absolutePath);
|
|
463
419
|
if (unsafe)
|
|
464
|
-
return
|
|
420
|
+
return { reason: unsafe };
|
|
465
421
|
if ((await stat(located.absolutePath)).size > MAX_TEMPLATE_BYTES) {
|
|
466
|
-
return
|
|
422
|
+
return { reason: "size_exceeded" };
|
|
467
423
|
}
|
|
468
424
|
bytes = await readFile(located.absolutePath);
|
|
469
425
|
const decoded = decodeTemplate(bytes);
|
|
470
426
|
if (decoded === undefined)
|
|
471
|
-
return
|
|
427
|
+
return { reason: "template_parse_error" };
|
|
472
428
|
text = decoded;
|
|
473
429
|
}
|
|
474
430
|
// Shared cap: the external path also checks the size before reading, inline checks here.
|
|
475
431
|
if (bytes.length > MAX_TEMPLATE_BYTES)
|
|
476
|
-
return
|
|
432
|
+
return { reason: "size_exceeded" };
|
|
433
|
+
return { text, bytes, offset: located.container === "inline" ? located.offset : 0 };
|
|
434
|
+
}
|
|
435
|
+
function toUsages(context, cls, located, read, raw) {
|
|
436
|
+
const fileText = located.container === "inline" ? cls.getSourceFile().getFullText() : read.text;
|
|
437
|
+
const fileHash = createHash("sha256").update(read.bytes).digest("hex");
|
|
438
|
+
return raw.map((usage) => {
|
|
439
|
+
const start = usage.start + read.offset;
|
|
440
|
+
return {
|
|
441
|
+
...usage,
|
|
442
|
+
absolutePath: located.absolutePath,
|
|
443
|
+
file: path.relative(context.projectRoot, located.absolutePath),
|
|
444
|
+
container: located.container,
|
|
445
|
+
start,
|
|
446
|
+
end: usage.end + read.offset,
|
|
447
|
+
...lineColumn(fileText, start),
|
|
448
|
+
fileHash,
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/** Selector and public binding names, or undefined when bindings cannot be named from the member. */
|
|
453
|
+
function bindingTarget(cls, node, name) {
|
|
454
|
+
const metadata = componentDecorator(cls)?.getArguments()[0];
|
|
455
|
+
if (!Node.isObjectLiteralExpression(metadata) || metadata.getProperty("hostDirectives")) {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
const property = metadata.getProperty("selector");
|
|
459
|
+
const selector = literalString(Node.isPropertyAssignment(property) ? property.getInitializer() : undefined);
|
|
460
|
+
const names = isMetadataPublic(cls, name)
|
|
461
|
+
? // A member both decorated and listed in metadata has ambiguous binding names.
|
|
462
|
+
isSelectorPublic(node)
|
|
463
|
+
? undefined
|
|
464
|
+
: metadataBindingNames(cls, name)
|
|
465
|
+
: bindingNames(node, name);
|
|
466
|
+
if (!selector || !names)
|
|
467
|
+
return undefined;
|
|
468
|
+
return { selector, inputs: names.inputs, outputs: names.outputs };
|
|
469
|
+
}
|
|
470
|
+
/** Parses a consumer template, or returns undefined when the compiler reports errors. */
|
|
471
|
+
function parseConsumer(compiler, text, absolutePath) {
|
|
472
|
+
const parsed = compiler.parseTemplate(text, absolutePath, { preserveWhitespaces: true });
|
|
473
|
+
return parsed.errors?.length ? undefined : parsed.nodes;
|
|
474
|
+
}
|
|
475
|
+
function selectorInspector(compiler, target) {
|
|
476
|
+
return (text, absolutePath, tick) => {
|
|
477
|
+
if (!mayMatch(compiler, target.selector, text))
|
|
478
|
+
return "skip";
|
|
479
|
+
const nodes = parseConsumer(compiler, text, absolutePath);
|
|
480
|
+
if (!nodes)
|
|
481
|
+
return "template_parse_error";
|
|
482
|
+
const scan = extractConsumerUsages(compiler, nodes, text, target, tick);
|
|
483
|
+
const usages = scan.usages.map((usage) => ({
|
|
484
|
+
...usage,
|
|
485
|
+
localsInScope: [],
|
|
486
|
+
role: "consumer",
|
|
487
|
+
}));
|
|
488
|
+
return { matched: scan.matched, opaque: scan.opaque, usages };
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function pipeInspector(compiler, name, rc) {
|
|
492
|
+
return (text, absolutePath, tick) => {
|
|
493
|
+
if (!text.includes(name))
|
|
494
|
+
return "skip";
|
|
495
|
+
const nodes = parseConsumer(compiler, text, absolutePath);
|
|
496
|
+
if (!nodes)
|
|
497
|
+
return "template_parse_error";
|
|
498
|
+
try {
|
|
499
|
+
const raw = extractUsages(compiler, nodes, { pipe: name, text }, rc, tick);
|
|
500
|
+
const usages = raw.map((usage) => ({ ...usage, role: "consumer" }));
|
|
501
|
+
return { matched: usages.length > 0, opaque: false, usages };
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
if (error instanceof SpanDrift)
|
|
505
|
+
return { matched: true, opaque: true, usages: [] };
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Scans every component template in the Project with `inspect`. Matching comes first; scope only
|
|
512
|
+
* filters matches. Unreadable templates and unproven scopes are reported.
|
|
513
|
+
*/
|
|
514
|
+
async function scanConsumers(context, cls, inspect, rc, limits) {
|
|
515
|
+
const remaining = { ...SCAN_LIMITS, ...limits };
|
|
516
|
+
const usages = [];
|
|
517
|
+
let failure;
|
|
518
|
+
let examined = 0;
|
|
519
|
+
const resolve = createScopeResolver(cls, {
|
|
520
|
+
coreMajor: angularCoreMajor(context),
|
|
521
|
+
budget: { scopeVisits: MAX_SCOPE_VISITS },
|
|
522
|
+
rc,
|
|
523
|
+
});
|
|
524
|
+
const tick = () => {
|
|
525
|
+
if (--remaining.nodes < 0)
|
|
526
|
+
throw new WorkExhausted();
|
|
527
|
+
if (remaining.nodes % CHECKPOINT_EVERY_NODES === 0)
|
|
528
|
+
rc.checkpoint();
|
|
529
|
+
};
|
|
530
|
+
try {
|
|
531
|
+
for (const consumer of componentClasses(context)) {
|
|
532
|
+
rc.checkpoint();
|
|
533
|
+
if (--remaining.templates < 0)
|
|
534
|
+
throw new WorkExhausted();
|
|
535
|
+
const located = locateTemplate(consumer);
|
|
536
|
+
if ("reason" in located) {
|
|
537
|
+
failure ??= located.reason;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const read = await readTemplate(context, located);
|
|
541
|
+
if ("reason" in read) {
|
|
542
|
+
failure ??= read.reason;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
remaining.bytes -= read.bytes.length;
|
|
546
|
+
if (remaining.bytes < 0)
|
|
547
|
+
throw new WorkExhausted();
|
|
548
|
+
examined += 1;
|
|
549
|
+
const scan = inspect(read.text, located.absolutePath, tick);
|
|
550
|
+
if (scan === "skip")
|
|
551
|
+
continue;
|
|
552
|
+
if (scan === "template_parse_error") {
|
|
553
|
+
failure ??= scan;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (scan.opaque)
|
|
557
|
+
failure ??= "cross_component";
|
|
558
|
+
if (!scan.matched)
|
|
559
|
+
continue;
|
|
560
|
+
const verdict = resolve(consumer);
|
|
561
|
+
if (verdict === "opaque")
|
|
562
|
+
failure ??= "cross_component";
|
|
563
|
+
if (verdict === "includes") {
|
|
564
|
+
usages.push(...toUsages(context, consumer, located, read, scan.usages));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
if (error instanceof WorkExhausted || error instanceof ScopeWorkExhausted) {
|
|
570
|
+
return { usages: [], failure: "work_exhausted", examined };
|
|
571
|
+
}
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
return { usages, failure, examined };
|
|
575
|
+
}
|
|
576
|
+
/** The literal `name` of a `@Pipe({ name })` class, or undefined when it is not a plain literal. */
|
|
577
|
+
function pipeName(cls) {
|
|
578
|
+
const decorator = cls.getDecorators().find((candidate) => isCoreDecorator(candidate, "Pipe"));
|
|
579
|
+
const metadata = decorator?.getArguments()[0];
|
|
580
|
+
if (!Node.isObjectLiteralExpression(metadata))
|
|
581
|
+
return undefined;
|
|
582
|
+
const property = metadata.getProperty("name");
|
|
583
|
+
return literalString(Node.isPropertyAssignment(property) ? property.getInitializer() : undefined);
|
|
584
|
+
}
|
|
585
|
+
/** `| name` usages of a pipe class in the templates of components that have the class in scope. */
|
|
586
|
+
async function collectPipeUsages(cls, context, rc, limits) {
|
|
587
|
+
const unfinished = (reason, templates = 0) => ({
|
|
588
|
+
coverage: { status: "unfinished", reason, templates },
|
|
589
|
+
usages: [],
|
|
590
|
+
});
|
|
591
|
+
const name = pipeName(cls);
|
|
592
|
+
if (name === undefined)
|
|
593
|
+
return unfinished("cross_component");
|
|
594
|
+
const loaded = await loadCompiler(context.tsConfigFilePath);
|
|
595
|
+
if ("reason" in loaded)
|
|
596
|
+
return unfinished(loaded.reason);
|
|
597
|
+
const scan = await scanConsumers(context, cls, pipeInspector(loaded.compiler, name, rc), rc, limits);
|
|
598
|
+
const coverage = scan.failure
|
|
599
|
+
? { status: "unfinished", reason: scan.failure, templates: scan.examined }
|
|
600
|
+
: { status: "completed", templates: scan.examined };
|
|
601
|
+
return { coverage, usages: scan.usages };
|
|
602
|
+
}
|
|
603
|
+
/** Collects usages of a component member inside its declaring component's template. */
|
|
604
|
+
export async function collectTemplateUsages(node, context, rc, limits = {}) {
|
|
477
605
|
rc.checkpoint();
|
|
478
|
-
|
|
606
|
+
if (!hasAngularCore(context))
|
|
607
|
+
return NOT_APPLICABLE;
|
|
608
|
+
const fail = (status, reason) => ({
|
|
609
|
+
coverage: { status, reason, templates: 0 },
|
|
610
|
+
usages: [],
|
|
611
|
+
});
|
|
612
|
+
if (Node.isClassDeclaration(node)) {
|
|
613
|
+
return isPipeClass(node) ? collectPipeUsages(node, context, rc, limits) : NOT_APPLICABLE;
|
|
614
|
+
}
|
|
615
|
+
const owner = memberOwner(node);
|
|
616
|
+
if (!owner)
|
|
617
|
+
return NOT_APPLICABLE;
|
|
618
|
+
if (isPipeClass(owner.cls)) {
|
|
619
|
+
// Only `transform` is the pipe contract: it reports the pipe's usages but a rename stays refused.
|
|
620
|
+
if (owner.name !== "transform" || componentDecorator(owner.cls)) {
|
|
621
|
+
return fail("unfinished", "cross_component");
|
|
622
|
+
}
|
|
623
|
+
return { ...(await collectPipeUsages(owner.cls, context, rc, limits)), renameBlocked: true };
|
|
624
|
+
}
|
|
625
|
+
const isComponent = componentDecorator(owner.cls) !== undefined;
|
|
626
|
+
const selectorPublic = isSelectorPublic(node) || isMetadataPublic(owner.cls, owner.name);
|
|
627
|
+
if (!isComponent && selectorPublic && isDirectiveClass(owner.cls)) {
|
|
628
|
+
return fail("unfinished", "cross_component");
|
|
629
|
+
}
|
|
630
|
+
const derived = hasDerivedComponent(context, owner.cls);
|
|
631
|
+
if (!isComponent && !derived)
|
|
632
|
+
return NOT_APPLICABLE;
|
|
633
|
+
if (derived)
|
|
634
|
+
return fail("unsupported", "inherited_member");
|
|
635
|
+
const loaded = await loadCompiler(context.tsConfigFilePath);
|
|
636
|
+
if ("reason" in loaded)
|
|
637
|
+
return fail("unfinished", loaded.reason);
|
|
638
|
+
const located = locateTemplate(owner.cls);
|
|
639
|
+
if ("reason" in located)
|
|
640
|
+
return fail("unfinished", located.reason);
|
|
641
|
+
const read = await readTemplate(context, located);
|
|
642
|
+
if ("reason" in read)
|
|
643
|
+
return fail("unfinished", read.reason);
|
|
644
|
+
rc.checkpoint();
|
|
645
|
+
const parsed = loaded.compiler.parseTemplate(read.text, located.absolutePath, {
|
|
479
646
|
preserveWhitespaces: true,
|
|
480
647
|
});
|
|
481
648
|
if (parsed.errors?.length)
|
|
482
649
|
return fail("unfinished", "template_parse_error");
|
|
483
650
|
let raw;
|
|
484
651
|
try {
|
|
485
|
-
raw = extractUsages(loaded.compiler, parsed.nodes, owner.name, rc);
|
|
652
|
+
raw = extractUsages(loaded.compiler, parsed.nodes, { member: owner.name }, rc);
|
|
486
653
|
}
|
|
487
654
|
catch (error) {
|
|
488
655
|
if (error instanceof WorkExhausted)
|
|
489
656
|
return fail("unfinished", "work_exhausted");
|
|
490
657
|
throw error;
|
|
491
658
|
}
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
start,
|
|
501
|
-
end: usage.end + offset,
|
|
502
|
-
...lineColumn(fileText, start),
|
|
503
|
-
kind: usage.kind,
|
|
504
|
-
localsInScope: usage.localsInScope,
|
|
505
|
-
fileHash,
|
|
506
|
-
};
|
|
659
|
+
const usages = toUsages(context, owner.cls, located, read, raw);
|
|
660
|
+
if (!selectorPublic)
|
|
661
|
+
return { coverage: { status: "completed", templates: 1 }, usages };
|
|
662
|
+
// Selector-public members are also bound in consuming templates: scan every component template.
|
|
663
|
+
const target = bindingTarget(owner.cls, node, owner.name);
|
|
664
|
+
const unfinished = (reason, templates = 1) => ({
|
|
665
|
+
coverage: { status: "unfinished", reason, templates },
|
|
666
|
+
usages,
|
|
507
667
|
});
|
|
508
|
-
|
|
668
|
+
if (!target)
|
|
669
|
+
return unfinished("cross_component");
|
|
670
|
+
if (!loaded.selectors)
|
|
671
|
+
return unfinished("compiler_unsupported");
|
|
672
|
+
const scan = await scanConsumers(context, owner.cls, selectorInspector(loaded.compiler, target), rc, limits);
|
|
673
|
+
const coverage = scan.failure
|
|
674
|
+
? { status: "unfinished", reason: scan.failure, templates: scan.examined }
|
|
675
|
+
: { status: "completed", templates: scan.examined };
|
|
676
|
+
// Names listed in `inputs` / `outputs` metadata are strings that a TypeScript rename cannot follow.
|
|
677
|
+
const renameBlocked = isMetadataPublic(owner.cls, owner.name);
|
|
678
|
+
return { coverage, usages: [...usages, ...scan.usages], renameBlocked };
|
|
509
679
|
}
|
|
@@ -38,7 +38,7 @@ export class H05LifecycleFixtureController {
|
|
|
38
38
|
const generationSignal = this.#generationAbort.signal, signal = AbortSignal.any([context.signal, generationSignal]);
|
|
39
39
|
const checkpoint = () => { if (!this.accepts(identity) || generationSignal.aborted)
|
|
40
40
|
throw new H03TimeoutFixtureError(); context.checkpoint(); };
|
|
41
|
-
const hooked = Object.freeze({ get sequence() { return context.sequence; }, get phase() { return context.phase; }, signal, checkpoint, markExecuting: () => context.markExecuting(), enterCompletionCritical: () => { checkpoint(); context.enterCompletionCritical(); } });
|
|
41
|
+
const hooked = Object.freeze({ get sequence() { return context.sequence; }, get phase() { return context.phase; }, signal, checkpoint, markExecuting: () => context.markExecuting(), remainingMs: () => context.remainingMs(), enterCompletionCritical: () => { checkpoint(); context.enterCompletionCritical(); } });
|
|
42
42
|
this.#active += 1;
|
|
43
43
|
this.#emit({ ...eventIdentity, phase: "started" });
|
|
44
44
|
try {
|
|
@@ -201,6 +201,7 @@ export class H03TimeoutFixtureController {
|
|
|
201
201
|
signal,
|
|
202
202
|
checkpoint,
|
|
203
203
|
markExecuting: () => context.markExecuting(),
|
|
204
|
+
remainingMs: () => context.remainingMs(),
|
|
204
205
|
enterCompletionCritical: () => {
|
|
205
206
|
checkpoint();
|
|
206
207
|
context.enterCompletionCritical();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const REVIEW_HINT = "Prepare again with allow_new_errors=true only after explicit review.";
|
|
2
|
+
/**
|
|
3
|
+
* A plan is blocked by new TypeScript errors, and, only when opted in, by new template errors of
|
|
4
|
+
* a `completed` check. A check that did not complete never blocks. `allow_new_errors` waives both.
|
|
5
|
+
*/
|
|
6
|
+
export function evaluateBlock(input) {
|
|
7
|
+
const templateErrors = input.blockOnTemplateErrors && input.template?.status === "completed"
|
|
8
|
+
? input.template.addedErrors.length
|
|
9
|
+
: 0;
|
|
10
|
+
const reasons = [
|
|
11
|
+
...(input.typescriptErrors > 0
|
|
12
|
+
? [`${input.typescriptErrors} new TypeScript error(s) would be introduced.`]
|
|
13
|
+
: []),
|
|
14
|
+
...(templateErrors > 0 ? [`${templateErrors} new template error(s) would be introduced.`] : []),
|
|
15
|
+
];
|
|
16
|
+
if (input.allowNewErrors || reasons.length === 0)
|
|
17
|
+
return { blocked: false, reason: null };
|
|
18
|
+
return { blocked: true, reason: `${reasons.join(" ")} ${REVIEW_HINT}` };
|
|
19
|
+
}
|
|
@@ -6,6 +6,8 @@ import { z } from "zod";
|
|
|
6
6
|
import { applyOperation, configureOperationApply, exportOperationRecord, importOperationRecord, } from "./operations.js";
|
|
7
7
|
import { ensurePrivateDirectory, resolveRuntimeStateDirectory, } from "./runtime-state.js";
|
|
8
8
|
export const PLAN_SCHEMA_VERSION = 2;
|
|
9
|
+
/** Plans that carry a template type-check delta persist as this version. */
|
|
10
|
+
export const TEMPLATE_PLAN_SCHEMA_VERSION = 3;
|
|
9
11
|
export const MAX_PLAN_BYTES = 50 * 1024 * 1024;
|
|
10
12
|
const HashSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
|
11
13
|
const IsoDateSchema = z.string().datetime({ offset: true });
|
|
@@ -38,8 +40,27 @@ const PersistedFileSchema = AffectedFileSchema.extend({
|
|
|
38
40
|
updated_bytes_base64: z.string(),
|
|
39
41
|
mode: z.number().int().nonnegative(),
|
|
40
42
|
}).strict();
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
+
const TemplateReasonSchema = z.enum([
|
|
44
|
+
"compiler_cli_missing",
|
|
45
|
+
"compiler_unsupported",
|
|
46
|
+
"typescript_incompatible",
|
|
47
|
+
"work_exhausted",
|
|
48
|
+
"deadline",
|
|
49
|
+
"check_failed",
|
|
50
|
+
]);
|
|
51
|
+
const TemplateDiagnosticsSchema = z.discriminatedUnion("status", [
|
|
52
|
+
DiagnosticDeltaSchema.extend({ status: z.literal("completed") }).strict(),
|
|
53
|
+
z
|
|
54
|
+
.object({
|
|
55
|
+
status: z.enum(["unavailable", "unfinished"]),
|
|
56
|
+
reason: TemplateReasonSchema,
|
|
57
|
+
added: z.array(DiagnosticSchema).max(0),
|
|
58
|
+
removed: z.array(DiagnosticSchema).max(0),
|
|
59
|
+
addedErrors: z.array(DiagnosticSchema).max(0),
|
|
60
|
+
})
|
|
61
|
+
.strict(),
|
|
62
|
+
]);
|
|
63
|
+
const PersistedOperationShape = {
|
|
43
64
|
operation_id: z.string().uuid(),
|
|
44
65
|
plan_hash: HashSchema,
|
|
45
66
|
kind: z.enum(["rename_symbol", "replace_symbol_body", "scaffold_class"]),
|
|
@@ -60,14 +81,24 @@ const PersistedOperationSchema = z
|
|
|
60
81
|
preview_truncated: z.boolean(),
|
|
61
82
|
applied_at: IsoDateSchema.optional(),
|
|
62
83
|
files: z.array(PersistedFileSchema).min(1),
|
|
84
|
+
};
|
|
85
|
+
const PersistedOperationSchema = z.object(PersistedOperationShape).strict();
|
|
86
|
+
const PersistedOperationV3Schema = z
|
|
87
|
+
.object({
|
|
88
|
+
...PersistedOperationShape,
|
|
89
|
+
template_diagnostics: TemplateDiagnosticsSchema,
|
|
90
|
+
block_on_template_errors: z.boolean(),
|
|
63
91
|
})
|
|
64
92
|
.strict();
|
|
65
93
|
const PlanEnvelopeSchema = z.discriminatedUnion("schema_version", [
|
|
66
94
|
z.object({ schema_version: z.literal(1), operation: PersistedOperationSchema }).strict(),
|
|
67
95
|
z.object({ schema_version: z.literal(2), operation: PersistedOperationSchema }).strict(),
|
|
96
|
+
z.object({ schema_version: z.literal(3), operation: PersistedOperationV3Schema }).strict(),
|
|
68
97
|
]);
|
|
69
98
|
function planEnvelope(version, operation) {
|
|
70
|
-
|
|
99
|
+
if (version === 1)
|
|
100
|
+
return { schema_version: 1, operation };
|
|
101
|
+
return version === 2 ? { schema_version: 2, operation } : { schema_version: 3, operation };
|
|
71
102
|
}
|
|
72
103
|
function stateDirectory(options) {
|
|
73
104
|
return resolveRuntimeStateDirectory(options);
|
|
@@ -148,7 +179,10 @@ async function readPlanEnvelope(planFileInput) {
|
|
|
148
179
|
export async function persistOperationPlan(operationId, options = {}) {
|
|
149
180
|
const plansDirectory = path.join(stateDirectory(options), "plans");
|
|
150
181
|
const planFile = path.join(plansDirectory, `${operationId}.astplan`);
|
|
151
|
-
|
|
182
|
+
const record = exportOperationRecord(operationId);
|
|
183
|
+
await atomicWritePlan(planFile, planEnvelope(record.template_diagnostics === undefined
|
|
184
|
+
? PLAN_SCHEMA_VERSION
|
|
185
|
+
: TEMPLATE_PLAN_SCHEMA_VERSION, record));
|
|
152
186
|
return planFile;
|
|
153
187
|
}
|
|
154
188
|
export async function applyPersistedOperation(planFileInput, planHash, options = {}) {
|