@cloudcare/hvigor-ohos-plugin 0.1.1 → 0.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Guance Session Replay page-root compiler transform.
3
+ *
4
+ * ArkUI exposes a FrameNode only after a component has an ID. The transform
5
+ * instruments @Entry page roots in Hvigor's in-memory source, leaving customer
6
+ * files untouched. The generated calls are deliberately tiny: the optional
7
+ * Replay HAR owns root selection, layout retries, privacy, and capture.
8
+ */
9
+
10
+ import { isApplicationArkTsSource } from './ft-console-log-transform';
11
+ import { hasRuntimeDependency, SourceReader, SourceDiagnosticReporter, parseApplicationSource,
12
+ reportSourceDiagnostic, reportSourceParseError } from './ft-transform-context';
13
+ import { allocateName as selectUniqueName } from './ft-generated-names';
14
+
15
+ type TypeScriptApi = any;
16
+
17
+ interface SourceRange {
18
+ start: number;
19
+ end: number;
20
+ }
21
+
22
+ interface MethodRange {
23
+ nameStart: number;
24
+ memberStart: number;
25
+ node: any;
26
+ bodyStart: number;
27
+ bodyEnd: number;
28
+ }
29
+
30
+ interface RootExpression {
31
+ end: number;
32
+ idArgument?: SourceRange;
33
+ hasAreaChange: boolean;
34
+ hasTouch: boolean;
35
+ touchArgument?: SourceRange;
36
+ }
37
+
38
+ interface EntryStruct {
39
+ node: any;
40
+ bodyEnd: number;
41
+ methods: Map<string, MethodRange>;
42
+ }
43
+
44
+ interface Injection {
45
+ position: number;
46
+ content: string;
47
+ }
48
+
49
+ const REPLAY_BRIDGE_IMPORT = '@guancecloud/ft_session_replay';
50
+ const REPLAY_ROOT_TRANSFORM_MARKER = '/* guance-session-replay-root-transform:instrumented */';
51
+
52
+ function lineIndentation(code: string, position: number): string {
53
+ const lineStart = code.lastIndexOf('\n', position) + 1;
54
+ const match = code.slice(lineStart, position).match(/^\s*/);
55
+ return match === null ? '' : match[0];
56
+ }
57
+
58
+ function collectIdentifiers(code: string): Set<string> {
59
+ const identifiers = new Set<string>();
60
+ const expression = /\b[A-Za-z_$][A-Za-z0-9_$]*\b/g;
61
+ let match: RegExpExecArray | null;
62
+ while ((match = expression.exec(code)) !== null) {
63
+ identifiers.add(match[0]);
64
+ }
65
+ return identifiers;
66
+ }
67
+
68
+ /** A stable opaque source key. Do not emit customers' project paths into UI IDs. */
69
+ function createSourceKey(id: string): string {
70
+ let hash = 0x811c9dc5;
71
+ const normalized = id.replace(/\\/g, '/').toLowerCase();
72
+ for (let index = 0; index < normalized.length; index++) {
73
+ hash ^= normalized.charCodeAt(index);
74
+ hash = Math.imul(hash, 0x01000193);
75
+ }
76
+ return (hash >>> 0).toString(36);
77
+ }
78
+
79
+ function findImportInsertionOffset(ts: TypeScriptApi, id: string, code: string): number {
80
+ const sourceFile = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true,
81
+ ts.ScriptKind.ETS ?? ts.ScriptKind.TS);
82
+ let offset = 0;
83
+ sourceFile.statements.forEach((statement: any): void => {
84
+ if (ts.isImportDeclaration(statement)) {
85
+ offset = statement.end;
86
+ }
87
+ });
88
+ return offset;
89
+ }
90
+
91
+ function findEntryDecorator(ts: TypeScriptApi, source: any): any {
92
+ let entry: any;
93
+ const visit = (node: any): void => {
94
+ if (ts.isDecorator(node)) {
95
+ const expression = ts.isCallExpression(node.expression) ? node.expression.expression : node.expression;
96
+ if (ts.isIdentifier(expression) && expression.text === 'Entry') entry ??= node;
97
+ }
98
+ ts.forEachChild(node, visit);
99
+ };
100
+ visit(source);
101
+ return entry;
102
+ }
103
+
104
+ /** Structs, decorators and members must come from the ArkTS parser, never text matches. */
105
+ function collectEntryStructs(ts: TypeScriptApi, source: any): EntryStruct[] {
106
+ const entries: EntryStruct[] = [];
107
+ source.statements.forEach((node: any) => {
108
+ if (!ts.isStructDeclaration(node)) return;
109
+ const decorators = ts.getDecorators ? ts.getDecorators(node) || [] : node.decorators || [];
110
+ const isEntry = decorators.some((decorator: any) => {
111
+ const expression = ts.isCallExpression(decorator.expression)
112
+ ? decorator.expression.expression : decorator.expression;
113
+ return ts.isIdentifier(expression) && expression.text === 'Entry';
114
+ });
115
+ if (!isEntry) return;
116
+ const methods = new Map<string, MethodRange>();
117
+ node.members.forEach((member: any) => {
118
+ if (ts.isMethodDeclaration(member) && member.body && ts.isIdentifier(member.name)) {
119
+ methods.set(member.name.text, { nameStart: member.name.getStart(source),
120
+ memberStart: member.getStart(source), node: member,
121
+ bodyStart: member.body.getStart(source), bodyEnd: member.body.end - 1 });
122
+ }
123
+ });
124
+ entries.push({ node, bodyEnd: node.end - 1, methods });
125
+ });
126
+ return entries;
127
+ }
128
+
129
+ function componentChain(ts: TypeScriptApi, expression: any): { base: any; modifiers: any[] } {
130
+ const modifiers: any[] = [];
131
+ let base = expression;
132
+ while (ts.isCallExpression(base) && ts.isPropertyAccessExpression(base.expression)) {
133
+ modifiers.unshift(base);
134
+ base = base.expression.expression;
135
+ }
136
+ return { base, modifiers };
137
+ }
138
+
139
+ function argumentRange(source: any, call: any): SourceRange {
140
+ // Include comments and newlines before the closing parenthesis when reusing an argument.
141
+ return { start: call.arguments.pos, end: call.getLastToken(source).getStart(source) };
142
+ }
143
+
144
+ function findRootExpression(ts: TypeScriptApi, source: any, code: string,
145
+ build: MethodRange): RootExpression | null {
146
+ const first = build.node.body.statements[0];
147
+ if (!first || (!ts.isExpressionStatement(first) && !ts.isReturnStatement(first)) || !first.expression) return null;
148
+ const { base, modifiers } = componentChain(ts, first.expression);
149
+ if (!ts.isEtsComponentExpression(base) || !base.body) return null;
150
+ let idArgument: SourceRange | undefined;
151
+ let hasAreaChange = false;
152
+ let hasTouch = false;
153
+ let touchArgument: SourceRange | undefined;
154
+ modifiers.forEach((call: any) => {
155
+ const name = call.expression.name.text;
156
+ const range = argumentRange(source, call);
157
+ if (name === 'id' && call.arguments.length === 1) {
158
+ const argument = call.arguments[0];
159
+ idArgument = { start: argument.getStart(source), end: argument.end };
160
+ }
161
+ if (name === 'onAreaChange') hasAreaChange = true;
162
+ if (name === 'onTouch') {
163
+ hasTouch = true;
164
+ if (call.arguments.length === 1) touchArgument = range;
165
+ }
166
+ });
167
+ return { end: first.expression.end, idArgument, hasAreaChange, hasTouch, touchArgument };
168
+ }
169
+
170
+ function applyInjections(code: string, injections: Injection[]): string {
171
+ const grouped = new Map<number, string[]>();
172
+ injections.forEach((injection: Injection): void => {
173
+ const values = grouped.get(injection.position) || [];
174
+ values.push(injection.content);
175
+ grouped.set(injection.position, values);
176
+ });
177
+ const positions = Array.from(grouped.keys()).sort((left: number, right: number): number => right - left);
178
+ let transformed = code;
179
+ positions.forEach((position: number): void => {
180
+ transformed = transformed.slice(0, position) + (grouped.get(position) || []).join('') + transformed.slice(position);
181
+ });
182
+ return transformed;
183
+ }
184
+
185
+ function addTransitionInjections(ts: TypeScriptApi, source: any, code: string, injections: Injection[],
186
+ entry: EntryStruct, bridgeName: string, tokenField: string, rootExpression: string): void {
187
+ const method = entry.methods.get('pageTransition');
188
+ if (!method) {
189
+ const indentation = `${lineIndentation(code, entry.bodyEnd)} `;
190
+ injections.push({ position: entry.bodyEnd, content: `\n${indentation}pageTransition(): void {\n` +
191
+ `${indentation} if (${bridgeName}.defaultTransitionStarted(this.${tokenField})) {}\n${indentation}}\n` });
192
+ return;
193
+ }
194
+ let nextSpec = 0;
195
+ method.node.body.statements.forEach((statement: any) => {
196
+ if (!ts.isExpressionStatement(statement)) return;
197
+ const { base, modifiers } = componentChain(ts, statement.expression);
198
+ if ((!ts.isCallExpression(base) && !ts.isEtsComponentExpression(base)) || !ts.isIdentifier(base.expression)) return;
199
+ const name = base.expression.text;
200
+ if (name !== 'PageTransitionEnter' && name !== 'PageTransitionExit') return;
201
+ const entering = name === 'PageTransitionEnter';
202
+ const callbackName = entering ? 'onEnter' : 'onExit';
203
+ const spec = JSON.stringify(`transition-${nextSpec++}`);
204
+ const prefix = `this.${tokenField}, ${rootExpression}, ${spec}, ${entering}`;
205
+ let callback: SourceRange | undefined;
206
+ modifiers.forEach((call: any) => {
207
+ const modifierName = call.expression.name.text;
208
+ const range = argumentRange(source, call);
209
+ if ((modifierName === 'slide' || modifierName === 'opacity') && call.arguments.length === 1) {
210
+ const hook = modifierName === 'slide' ? 'transitionSlide' : 'transitionOpacity';
211
+ injections.push({ position: range.start,
212
+ content: `${bridgeName}.${hook}(this.${tokenField}, ${spec}, ` });
213
+ injections.push({ position: range.end, content: ')' });
214
+ } else if (modifierName === callbackName && call.arguments.length === 1) callback = range;
215
+ });
216
+ if (callback) {
217
+ injections.push({ position: callback.start, content: `${bridgeName}.wrapTransition(${prefix}, ` });
218
+ injections.push({ position: callback.end, content: ')' });
219
+ } else {
220
+ injections.push({ position: statement.expression.end,
221
+ content: `.${callbackName}((type: RouteType, progress: number) => { ${bridgeName}.transitionProgress(${prefix}, progress); })` });
222
+ }
223
+ });
224
+ }
225
+
226
+ function lifecycleCall(bridgeName: string, tokenField: string, method: 'pageShown' | 'pageHidden' | 'pageDisposed',
227
+ rootExpression: string, observeDefaultTransition: boolean): string {
228
+ if (method === 'pageShown') {
229
+ return `${bridgeName}.pageShown(this.getUIContext(), this.${tokenField}, ${rootExpression}, vp2px(1), ${observeDefaultTransition});`;
230
+ }
231
+ return `${bridgeName}.${method}(this.${tokenField});`;
232
+ }
233
+
234
+ function addLifecycleInjection(
235
+ code: string,
236
+ injections: Injection[],
237
+ struct: EntryStruct,
238
+ buildMethod: MethodRange,
239
+ bridgeName: string,
240
+ tokenField: string,
241
+ methodName: 'onPageShow' | 'onPageHide' | 'aboutToDisappear',
242
+ runtimeMethod: 'pageShown' | 'pageHidden' | 'pageDisposed',
243
+ rootExpression: string,
244
+ visibleField?: string
245
+ ): void {
246
+ const existing = struct.methods.get(methodName);
247
+ const rawCall = lifecycleCall(bridgeName, tokenField, runtimeMethod, rootExpression,
248
+ !struct.methods.has('pageTransition'));
249
+ const call = !visibleField ? rawCall : methodName === 'onPageShow'
250
+ ? `this.${visibleField} = true; if (${rootExpression}.length > 0) { ${rawCall} }`
251
+ : `this.${visibleField} = false; ${rawCall}`;
252
+ if (existing) {
253
+ const indentation = `${lineIndentation(code, existing.nameStart)} `;
254
+ injections.push({ position: existing.bodyStart + 1, content: `\n${indentation}${call}` });
255
+ return;
256
+ }
257
+ const indentation = lineIndentation(code, buildMethod.nameStart);
258
+ injections.push({
259
+ position: buildMethod.memberStart,
260
+ content: `\n${indentation}${methodName}(): void {\n${indentation} ${call}\n${indentation}}\n\n`
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Adds an opaque root ID and page lifecycle registration to every @Entry page.
266
+ * ArkTS nodes locate real decorators, struct members and component expressions.
267
+ */
268
+ export function transformSessionReplaySource(ts: TypeScriptApi, id: string, code: string, readSource?: SourceReader,
269
+ reportDiagnostic?: SourceDiagnosticReporter): string {
270
+ if (!isApplicationArkTsSource(id) || code.includes(REPLAY_ROOT_TRANSFORM_MARKER) ||
271
+ !hasRuntimeDependency(ts, id, '@guancecloud/ft_sdk', readSource) ||
272
+ !hasRuntimeDependency(ts, id, REPLAY_BRIDGE_IMPORT, readSource)) {
273
+ return code;
274
+ }
275
+
276
+ const preliminarySource = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true,
277
+ ts.ScriptKind.ETS ?? ts.ScriptKind.TS);
278
+ const report = (source: any, position: number, reason: string, message: string): void => {
279
+ reportSourceDiagnostic(source, position, reason, message, reportDiagnostic);
280
+ };
281
+ if (typeof ts.isStructDeclaration !== 'function' || typeof ts.isEtsComponentExpression !== 'function') {
282
+ // Even a standard TypeScript parser retains real decorator nodes. Do not
283
+ // mistake @Entry text inside comments or strings for a page needing Replay.
284
+ const entryDecorator = findEntryDecorator(ts, preliminarySource);
285
+ if (entryDecorator) report(preliminarySource, entryDecorator.getStart(preliminarySource), 'unsupported-parser',
286
+ 'The installed compiler does not expose the ArkTS struct/component parser required for automatic Replay roots.');
287
+ return code;
288
+ }
289
+ const source = parseApplicationSource(ts, id, code, preliminarySource);
290
+ const entries = collectEntryStructs(ts, source);
291
+ if (entries.length === 0 && !findEntryDecorator(ts, source)) return code;
292
+ if (reportSourceParseError(ts, source, reportDiagnostic)) return code;
293
+
294
+ const identifiers = collectIdentifiers(code);
295
+ const bridgeName = selectUniqueName(identifiers, '__ftSessionReplayAutoRootBridge');
296
+ const tokenField = selectUniqueName(identifiers, '__ftSessionReplayRootToken');
297
+ const sourceKey = createSourceKey(id);
298
+ const injections: Injection[] = [];
299
+
300
+ entries.forEach((entry: EntryStruct): void => {
301
+ const buildMethod = entry.methods.get('build');
302
+ if (!buildMethod) {
303
+ report(source, entry.node.name.getStart(source), 'missing-build',
304
+ 'The @Entry page has no build() method with a body to instrument.');
305
+ return;
306
+ }
307
+ const root = findRootExpression(ts, source, code, buildMethod);
308
+ if (root === null) {
309
+ const first = buildMethod.node.body.statements[0];
310
+ report(source, first ? first.getStart(source) : buildMethod.bodyStart + 1, 'unsupported-root',
311
+ 'build() must start with a supported ArkUI root component with a child block for automatic Replay registration.');
312
+ return;
313
+ }
314
+ const indentation = lineIndentation(code, buildMethod.nameStart);
315
+ injections.push({
316
+ position: buildMethod.memberStart,
317
+ content: `\n${indentation}private ${tokenField}: string = ${bridgeName}.createRootToken(${JSON.stringify(sourceKey)});\n\n`
318
+ });
319
+
320
+ let rootExpression = `this.${tokenField}`;
321
+ let visibleField: string | undefined;
322
+ if (root.idArgument) {
323
+ const idField = selectUniqueName(identifiers, '__ftSessionReplayRootId');
324
+ visibleField = selectUniqueName(identifiers, '__ftSessionReplayVisible');
325
+ const capture = selectUniqueName(identifiers, '__ftSessionReplayCaptureId');
326
+ const value = selectUniqueName(identifiers, '__ftSessionReplayIdValue');
327
+ rootExpression = `this.${idField}`;
328
+ const shown = lifecycleCall(bridgeName, tokenField, 'pageShown', rootExpression,
329
+ !entry.methods.has('pageTransition'));
330
+ injections.push({ position: buildMethod.memberStart,
331
+ content: `\n${indentation}private ${idField}: string = '';\n` +
332
+ `${indentation}private ${visibleField}: boolean = false;\n` +
333
+ `${indentation}private ${capture}(${value}: string): string {\n` +
334
+ `${indentation} const changed: boolean = this.${idField} !== ${value};\n` +
335
+ `${indentation} this.${idField} = ${value};\n` +
336
+ `${indentation} if (changed && this.${visibleField} && ${value}.length > 0) { ${shown} }\n` +
337
+ `${indentation} return ${value};\n${indentation}}\n` });
338
+ injections.push({ position: root.idArgument.start, content: `this.${capture}(` });
339
+ injections.push({ position: root.idArgument.end, content: ')' });
340
+ } else {
341
+ injections.push({ position: root.end, content: `.id(${rootExpression})` });
342
+ }
343
+ // Root area changes are ArkUI's public layout/draw boundary. Emit a
344
+ // coalesced Replay signal there instead of installing a fixed full-tree
345
+ // polling loop. Do not add a second modifier when an app already owns one.
346
+ if (!root.hasAreaChange) {
347
+ injections.push({
348
+ position: root.end,
349
+ content: `.onAreaChange(() => { ${bridgeName}.pageChanged(this.${tokenField}); })`
350
+ });
351
+ }
352
+ // Preserve application callbacks and their event propagation behavior.
353
+ // The bridge forwards real pointer coordinates independently of drawing.
354
+ if (!root.hasTouch) {
355
+ injections.push({
356
+ position: root.end,
357
+ content: `.onTouch((event: TouchEvent) => { ${bridgeName}.pageTouch(this.${tokenField}, event); })`
358
+ });
359
+ } else if (root.touchArgument !== undefined) {
360
+ injections.push({ position: root.touchArgument.start,
361
+ content: `${bridgeName}.wrapTouch(this.${tokenField}, ` });
362
+ injections.push({ position: root.touchArgument.end, content: ')' });
363
+ }
364
+ addLifecycleInjection(code, injections, entry, buildMethod, bridgeName, tokenField,
365
+ 'onPageShow', 'pageShown', rootExpression, visibleField);
366
+ addLifecycleInjection(code, injections, entry, buildMethod, bridgeName, tokenField,
367
+ 'onPageHide', 'pageHidden', rootExpression, visibleField);
368
+ addLifecycleInjection(code, injections, entry, buildMethod, bridgeName, tokenField,
369
+ 'aboutToDisappear', 'pageDisposed', rootExpression, visibleField);
370
+ addTransitionInjections(ts, source, code, injections, entry, bridgeName, tokenField, rootExpression);
371
+ });
372
+
373
+ if (injections.length === 0) {
374
+ return code;
375
+ }
376
+ const importOffset = findImportInsertionOffset(ts, id, code);
377
+ const importStatement = `\n${REPLAY_ROOT_TRANSFORM_MARKER}\n` +
378
+ `import { FTSessionReplayAutoRootBridge as ${bridgeName} } from '${REPLAY_BRIDGE_IMPORT}';\n`;
379
+ injections.push({ position: importOffset, content: importStatement });
380
+ return applyInjections(code, injections);
381
+ }
@@ -12,6 +12,9 @@ import {
12
12
  transformConsoleLogSource
13
13
  } from './ft-console-log-transform';
14
14
  import { transformColdLaunchSource } from './ft-cold-launch-transform';
15
+ import { transformSessionReplaySource } from './ft-session-replay-transform';
16
+ import { transformWebReplaySource } from './ft-web-replay-transform';
17
+ import { SourceDiagnosticReporter, SourceTransformDiagnostic } from './ft-transform-context';
15
18
 
16
19
  declare const require: {
17
20
  main: {
@@ -20,7 +23,9 @@ declare const require: {
20
23
  };
21
24
 
22
25
  type TypeScriptApi = any;
23
- type SourceTransform = (ts: TypeScriptApi, fileName: string, code: string) => string;
26
+ type SourceReader = (fileName: string) => string | undefined;
27
+ type SourceTransform = (ts: TypeScriptApi, fileName: string, code: string, readSource?: SourceReader,
28
+ reportDiagnostic?: SourceDiagnosticReporter) => string;
24
29
 
25
30
  interface CompilePluginContext {
26
31
  share: {
@@ -32,6 +37,8 @@ interface CompilePluginContext {
32
37
 
33
38
  interface SourceTransformDefinition {
34
39
  id: string;
40
+ prefix: string;
41
+ feature: string;
35
42
  transform: SourceTransform;
36
43
  }
37
44
 
@@ -39,21 +46,64 @@ interface SharedSourceTransformRegistry {
39
46
  originalReadFile: (fileName: string, options?: any) => any;
40
47
  buffer: any;
41
48
  transforms: Map<string, (fileName: string, code: string) => string>;
49
+ owners: Map<object, Map<string, (fileName: string, code: string) => string>>;
50
+ reportedDiagnostics: Set<string>;
42
51
  }
43
52
 
44
53
  const SOURCE_TRANSFORM_REGISTRY_KEY = '__guanceHvigorSourceTransformRegistry';
45
54
  const SOURCE_TRANSFORMS: SourceTransformDefinition[] = [
46
- { id: 'guance-console-log-transform', transform: transformConsoleLogSource },
47
- { id: 'guance-cold-launch-transform', transform: transformColdLaunchSource }
55
+ { id: 'guance-console-log-transform', prefix: 'GuanceLog', feature: 'console-log', transform: transformConsoleLogSource },
56
+ { id: 'guance-cold-launch-transform', prefix: 'GuanceColdLaunch', feature: 'cold-launch', transform: transformColdLaunchSource },
57
+ { id: 'guance-session-replay-root-transform', prefix: 'GuanceReplay', feature: 'session-replay-root', transform: transformSessionReplaySource },
58
+ { id: 'guance-web-replay-transform', prefix: 'GuanceWebReplay', feature: 'web-replay', transform: transformWebReplaySource }
48
59
  ];
49
60
 
50
- let registeredFileSystem: any = null;
61
+ const registrations: Array<{ fileSystem: any; owner: object; context: CompilePluginContext }> = [];
51
62
 
52
- function installSourceReader(context: CompilePluginContext): void {
53
- if (registeredFileSystem !== null) {
54
- return;
55
- }
63
+ function refreshTransforms(registry: SharedSourceTransformRegistry): void {
64
+ registry.transforms.clear();
65
+ registry.owners.forEach(transforms => transforms.forEach((transform, id) => registry.transforms.set(id, transform)));
66
+ }
67
+
68
+ function createDiagnosticReporter(registry: SharedSourceTransformRegistry, ts: TypeScriptApi,
69
+ readSource: SourceReader, definition: SourceTransformDefinition):
70
+ (diagnostic: SourceTransformDiagnostic, inputFile: string, inputCode: string, occurrence?: number) => void {
71
+ let logger: { warn(message: string): void } | undefined;
72
+ return (diagnostic: SourceTransformDiagnostic, inputFile: string, inputCode: string, occurrence = 0): void => {
73
+ // Earlier transforms can insert imports or wrappers. Recheck only failed
74
+ // pages against the on-disk source so clickable locations refer to app code.
75
+ const originalCode = readSource(diagnostic.fileName);
76
+ if (diagnostic.reason !== 'transform-exception' && diagnostic.fileName === inputFile &&
77
+ originalCode !== undefined && originalCode !== inputCode) {
78
+ const originalDiagnostics: SourceTransformDiagnostic[] = [];
79
+ try {
80
+ definition.transform(ts, diagnostic.fileName, originalCode, readSource, candidate => {
81
+ if (candidate.reason === diagnostic.reason &&
82
+ (candidate.message === diagnostic.message || candidate.reason === 'parse-error')) originalDiagnostics.push(candidate);
83
+ });
84
+ } catch (_) {
85
+ // Location mapping is diagnostic-only and must not change build behavior.
86
+ }
87
+ diagnostic = originalDiagnostics[occurrence] ?? { ...diagnostic,
88
+ message: `${diagnostic.message} (Location refers to in-memory transformed source.)` };
89
+ }
90
+ const key = `${definition.id}:${JSON.stringify(diagnostic)}`;
91
+ if (registry.reportedDiagnostics.has(key)) return;
92
+ registry.reportedDiagnostics.add(key);
93
+ const message = `[GuanceBuild][${definition.prefix}] ${diagnostic.fileName}:${diagnostic.line}:${diagnostic.column}: ` +
94
+ `feature=${definition.feature}; reason=${diagnostic.reason}; ${diagnostic.message}`;
95
+ if (!logger) {
96
+ try {
97
+ logger = require.main.require('@ohos/hvigor').HvigorLogger.getLogger(definition.prefix);
98
+ } catch (_) {
99
+ logger = { warn: (value: string): void => console.warn(value) };
100
+ }
101
+ }
102
+ logger!.warn(message);
103
+ };
104
+ }
56
105
 
106
+ function installSourceReader(context: CompilePluginContext): void {
57
107
  const typeScriptPath = `${context.share.projectConfig.etsLoaderPath}/node_modules/typescript`;
58
108
  const ts = require.main.require(typeScriptPath);
59
109
  const fileSystem = require.main.require('fs');
@@ -62,7 +112,9 @@ function installSourceReader(context: CompilePluginContext): void {
62
112
  registry = {
63
113
  originalReadFile: fileSystem.readFileSync as (fileName: string, options?: any) => any,
64
114
  buffer: require.main.require('buffer'),
65
- transforms: new Map<string, (fileName: string, code: string) => string>()
115
+ transforms: new Map<string, (fileName: string, code: string) => string>(),
116
+ owners: new Map(),
117
+ reportedDiagnostics: new Set()
66
118
  };
67
119
  fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY] = registry;
68
120
  fileSystem.readFileSync = (fileName: string, options?: any): any => {
@@ -78,26 +130,63 @@ function installSourceReader(context: CompilePluginContext): void {
78
130
  };
79
131
  }
80
132
 
133
+ const readSource: SourceReader = (fileName: string): string | undefined => {
134
+ try {
135
+ return registry!.originalReadFile(fileName, 'utf8').toString();
136
+ } catch (_) {
137
+ return undefined;
138
+ }
139
+ };
140
+ // A daemon may retain a registry installed by an earlier plugin version.
141
+ registry.reportedDiagnostics ??= new Set();
142
+ const owner = {};
143
+ const transforms = new Map<string, (fileName: string, code: string) => string>();
81
144
  SOURCE_TRANSFORMS.forEach((definition: SourceTransformDefinition) => {
82
- registry!.transforms.set(definition.id, (fileName: string, code: string) =>
83
- definition.transform(ts, fileName, code));
145
+ const reportDiagnostic = createDiagnosticReporter(registry!, ts, readSource, definition);
146
+ transforms.set(definition.id, (fileName: string, code: string) => {
147
+ const occurrences = new Map<string, number>();
148
+ try {
149
+ return definition.transform(ts, fileName, code, readSource,
150
+ diagnostic => {
151
+ const key = `${diagnostic.reason}:${diagnostic.message}`;
152
+ const occurrence = occurrences.get(key) ?? 0;
153
+ occurrences.set(key, occurrence + 1);
154
+ reportDiagnostic(diagnostic, fileName, code, occurrence);
155
+ });
156
+ } catch (error) {
157
+ try {
158
+ reportDiagnostic({ fileName, line: 1, column: 1, reason: 'transform-exception',
159
+ message: `Source transform failed: ${error instanceof Error ? error.message : String(error)}` }, fileName, code);
160
+ } finally {
161
+ // Preserve the original failure: diagnostics must never turn a broken
162
+ // transform into a successful build or replace its exception.
163
+ throw error;
164
+ }
165
+ }
166
+ });
84
167
  });
85
- registeredFileSystem = fileSystem;
168
+ registry.owners.set(owner, transforms);
169
+ refreshTransforms(registry);
170
+ registrations.push({ fileSystem, owner, context });
86
171
  }
87
172
 
88
- function restoreSourceReader(): void {
89
- if (registeredFileSystem === null) {
90
- return;
173
+ function restoreSourceReader(context: CompilePluginContext): void {
174
+ let index = registrations.length - 1;
175
+ for (let candidate = registrations.length - 1; candidate >= 0; candidate--) {
176
+ if (registrations[candidate].context === context) { index = candidate; break; }
91
177
  }
92
- const registry = registeredFileSystem[SOURCE_TRANSFORM_REGISTRY_KEY] as SharedSourceTransformRegistry | undefined;
178
+ const registration = index < 0 ? undefined : registrations.splice(index, 1)[0];
179
+ if (!registration) return;
180
+ const { fileSystem, owner } = registration;
181
+ const registry = fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY] as SharedSourceTransformRegistry | undefined;
93
182
  if (registry) {
94
- SOURCE_TRANSFORMS.forEach((definition: SourceTransformDefinition) => registry!.transforms.delete(definition.id));
95
- if (registry.transforms.size === 0) {
96
- registeredFileSystem.readFileSync = registry.originalReadFile;
97
- delete registeredFileSystem[SOURCE_TRANSFORM_REGISTRY_KEY];
183
+ registry.owners.delete(owner);
184
+ refreshTransforms(registry);
185
+ if (registry.owners.size === 0) {
186
+ fileSystem.readFileSync = registry.originalReadFile;
187
+ delete fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY];
98
188
  }
99
189
  }
100
- registeredFileSystem = null;
101
190
  }
102
191
 
103
192
  export default {
@@ -106,7 +195,7 @@ export default {
106
195
  buildStart(this: CompilePluginContext): void {
107
196
  installSourceReader(this);
108
197
  },
109
- buildEnd(): void {
110
- restoreSourceReader();
198
+ buildEnd(this: CompilePluginContext): void {
199
+ restoreSourceReader(this);
111
200
  }
112
201
  };
@@ -0,0 +1,91 @@
1
+ /** Shared, source-module-specific guards for compiler transforms. */
2
+ declare const require: any;
3
+
4
+ export type SourceReader = (fileName: string) => string | undefined;
5
+
6
+ export interface SourceTransformDiagnostic {
7
+ fileName: string;
8
+ line: number;
9
+ column: number;
10
+ reason: string;
11
+ message: string;
12
+ }
13
+
14
+ export type SourceDiagnosticReporter = (diagnostic: SourceTransformDiagnostic) => void;
15
+
16
+ export function reportSourceDiagnostic(source: any, position: number, reason: string, message: string,
17
+ reporter?: SourceDiagnosticReporter): void {
18
+ if (!reporter) return;
19
+ const location = source.getLineAndCharacterOfPosition(position);
20
+ reporter({ fileName: source.fileName, line: location.line + 1, column: location.character + 1, reason, message });
21
+ }
22
+
23
+ /** Parse declarative child blocks without treating enum members as components. */
24
+ export function parseApplicationSource(ts: any, id: string, code: string, preliminary?: any): any {
25
+ const source = preliminary ?? ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true,
26
+ ts.ScriptKind.ETS ?? ts.ScriptKind.TS);
27
+ if (!id.endsWith('.ets') || typeof ts.isStructDeclaration !== 'function' ||
28
+ typeof ts.isEtsComponentExpression !== 'function') return source;
29
+ const names = new Set<string>();
30
+ const visit = (node: any): void => {
31
+ if ((ts.isCallExpression(node) || ts.isEtsComponentExpression(node)) && ts.isIdentifier(node.expression)) {
32
+ names.add(node.expression.text);
33
+ }
34
+ ts.forEachChild(node, visit);
35
+ };
36
+ visit(source);
37
+ const components = Array.from(names).filter(name => !['ForEach', 'LazyForEach', 'Repeat'].includes(name));
38
+ return ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.ETS, { ets: {
39
+ components,
40
+ render: { method: ['build', 'pageTransition'], decorator: ['Builder', 'LocalBuilder'] },
41
+ extend: { decorator: ['Extend', 'AnimatableExtend'], components: components.map(name =>
42
+ ({ name, type: `${name}Attribute`, instance: `${name}Instance` })) },
43
+ styles: { decorator: 'Styles', component: { name: 'Common', type: 'T', instance: 'CommonInstance' }, property: 'stateStyles' },
44
+ concurrent: { decorator: 'Concurrent' },
45
+ customComponent: 'CustomComponent',
46
+ syntaxComponents: { paramsUICallback: ['ForEach', 'LazyForEach'],
47
+ attrUICallback: [{ name: 'Repeat', attributes: ['each', 'template'] }] }
48
+ } });
49
+ }
50
+
51
+ export function reportSourceParseError(ts: any, source: any, reporter?: SourceDiagnosticReporter): boolean {
52
+ if (source.parseDiagnostics.length === 0) return false;
53
+ const diagnostic = source.parseDiagnostics[0];
54
+ const remaining = source.parseDiagnostics.length - 1;
55
+ reportSourceDiagnostic(source, diagnostic.start ?? 0, 'parse-error',
56
+ `TS${diagnostic.code}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}${
57
+ remaining > 0 ? ` (${remaining} additional parse errors)` : ''}`, reporter);
58
+ return true;
59
+ }
60
+
61
+ export function objectProperty(ts: any, object: any, name: string): any | undefined {
62
+ if (!object || !ts.isObjectLiteralExpression(object)) return undefined;
63
+ const matches = object.properties.filter((item: any) => ts.isPropertyAssignment(item) &&
64
+ (ts.isIdentifier(item.name) || ts.isStringLiteral(item.name)) && item.name.text === name);
65
+ return matches.length > 0 ? matches[matches.length - 1].initializer : undefined;
66
+ }
67
+
68
+ /** Read JSON5 as data syntax, without evaluating package contents. */
69
+ export function hasRuntimeDependency(ts: any, id: string, packageName: string, readSource?: SourceReader): boolean {
70
+ const normalized = id.replace(/\\/g, '/');
71
+ const sourceIndex = normalized.lastIndexOf('/src/main/ets/');
72
+ if (sourceIndex < 0) return false;
73
+ const packagePath = normalized.slice(0, sourceIndex) + '/oh-package.json5';
74
+ try {
75
+ const nodeRequire = require.main && typeof require.main.require === 'function'
76
+ ? require.main.require.bind(require.main) : require;
77
+ const code = readSource ? readSource(packagePath) : nodeRequire('fs').readFileSync(packagePath, 'utf8');
78
+ if (typeof code !== 'string') return false;
79
+ const parsed = ts.createSourceFile(packagePath, `const __package = ${code}`,
80
+ ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
81
+ if (parsed.parseDiagnostics.length > 0 || parsed.statements.length !== 1 ||
82
+ !ts.isVariableStatement(parsed.statements[0])) return false;
83
+ const root = parsed.statements[0].declarationList.declarations[0].initializer;
84
+ const dependency = objectProperty(ts, objectProperty(ts, root, 'dependencies'), packageName);
85
+ // Runtime imports require an explicit dependency in the owning module.
86
+ // Do not inherit the entry/root module's dependencies or match comments/devDependencies.
87
+ return !!dependency && ts.isStringLiteral(dependency) && dependency.text.trim().length > 0;
88
+ } catch (_) {
89
+ return false;
90
+ }
91
+ }