@cloudcare/hvigor-ohos-plugin 0.1.2-alpha.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.2-alpha.2] - 2026-10-30
4
+
5
+ - Added build warnings with feature names, source paths, skip reasons and source locations for Console/HiLog, cold-launch lifecycle hooks, Session Replay root registration and ArkWeb binding.
6
+ - Fixed automatic Session Replay page registration being skipped on pages that use UI enum values or `ForEach`/`LazyForEach`/`Repeat` child blocks, which could leave native View/Action events without `session_has_replay`.
7
+
3
8
  ## [0.1.2-alpha.1] - 2026-10-19
4
9
 
5
10
  - Added automatic Session Replay integration for supported ArkUI `@Entry` pages, ArkWeb components and touch events, without manual page registration or event forwarding. Requires `@guancecloud/ft_session_replay` in the HAP and Replay initialization; touch coordinates require `TouchPrivacy.SHOW`.
package/README.md CHANGED
@@ -46,6 +46,28 @@ The plugin automatically captures page touch events without changing existing `o
46
46
 
47
47
  Router page changes and explicit `PageTransitionEnter/Exit` slide and opacity animations are captured automatically. Existing transition callbacks are preserved; playback of default system transitions may be approximate.
48
48
 
49
+ ## Build diagnostics
50
+
51
+ Search for `[GuanceBuild]` in DevEco Studio's Build output or `.hvigor/outputs/build-logs/build.log` to find instrumentation warnings. Each warning includes a feature name, source path, one-based line and column, skip reason, and explanation. These build-time warnings do not depend on `FTSDKConfig.setDebug(...)`.
52
+
53
+ | Filter | Feature | Feature-specific skip reasons |
54
+ | --- | --- | --- |
55
+ | `[GuanceLog]` | Console/HiLog collection | `unsupported-log-call`: optional calls, missing HiLog arguments, or an unusable compiler source range |
56
+ | `[GuanceColdLaunch]` | AbilityStage/UIAbility/InsightIntent lifecycle and first-frame hooks | `unresolved-ability-base`: a declared module entry's platform base cannot be resolved; `unsupported-lifecycle`: no instrumentable method/function body; `unsupported-lifecycle-parameters`: unsupported launch or WindowStage parameters |
57
+ | `[GuanceReplay]` | Session Replay `@Entry` page root registration | `unsupported-parser`, `missing-build`, `unsupported-root` |
58
+ | `[GuanceWebReplay]` | ArkWeb controller binding | `unsupported-web-call`: a `Web` call is optional, has a spread argument, or does not have exactly one options argument |
59
+
60
+ All four features report `parse-error` when a relevant source file cannot be parsed, including the first compiler diagnostic and the number of additional parse errors. Unexpected transform exceptions report `transform-exception` and retain the original build failure. When an exception has no source position, its location is the file's `1:1`.
61
+
62
+ ```text
63
+ [GuanceBuild][GuanceReplay] /project/entry/src/main/ets/pages/Example.ets:12:19: feature=session-replay-root; reason=parse-error; TS1109: Expression expected.
64
+ [GuanceBuild][GuanceColdLaunch] /project/entry/src/main/ets/entryability/EntryAbility.ets:18:3: feature=cold-launch; reason=unsupported-lifecycle-parameters; onWindowStageCreate requires a named WindowStage parameter for automatic first-frame observation.
65
+ ```
66
+
67
+ Warnings do not fail the build by themselves. Unsupported calls/hooks are left unchanged while other supported hooks can still be applied; a parse failure skips that feature's transformation for the file. Application files are never modified on disk. Locations refer to the original source when available; a warning explicitly identifies any location that refers to transformed source instead. Identical warnings are deduplicated per feature and source location within the active compilation.
68
+
69
+ Modules without the required runtime dependencies, files with no matching calls/classes/pages, and locally shadowed names are skipped without warnings. The plugin does not infer that an arbitrary unresolved business class is an Ability. After updating the plugin, rebuild the application so the source transforms execute again. Existing `ArkTS:WARN` compiler diagnostics remain independent of these plugin warnings.
70
+
49
71
  ## Compatibility
50
72
 
51
73
  The plugin is verified with Node.js 18 or newer, Hvigor and `@ohos/hvigor-ohos-plugin` 6.23.5, and HarmonyOS SDK 6.0.2 (API 22). Console/HiLog and cold-launch instrumentation require `@guancecloud/ft_sdk` `0.1.17` or later. Automatic Session Replay additionally requires `@guancecloud/ft_sdk` `0.1.18` or later and `@guancecloud/ft_session_replay` `0.1.0` or later.
@@ -59,4 +81,4 @@ Each module that needs automatic collection must declare these SDK packages unde
59
81
 
60
82
  The plugin checks SDK dependencies in the `oh-package.json5` of the module that contains each source file (HAP, HAR or HSP). Declaring an SDK only in the project-root `oh-package.json5`, another module, or `devDependencies` does not enable automatic collection for that source file.
61
83
 
62
- Optional logging calls such as `console?.info(...)` and `hilog.info?.(...)` are not automatically collected.
84
+ Optional logging calls such as `console?.info(...)` and `hilog.info?.(...)` are not automatically collected and produce `unsupported-log-call` warnings.
@@ -5,7 +5,8 @@
5
5
  * the compiler's in-memory source. Application files are never modified.
6
6
  */
7
7
 
8
- import { hasRuntimeDependency } from './ft-transform-context';
8
+ import { hasRuntimeDependency, objectProperty, SourceDiagnosticReporter, SourceTransformDiagnostic,
9
+ parseApplicationSource, reportSourceDiagnostic, reportSourceParseError } from './ft-transform-context';
9
10
  import { createLexicalResolver } from './ft-lexical-scope';
10
11
  import { allocateName as selectIdentifierName, collectIdentifierNames } from './ft-generated-names';
11
12
 
@@ -42,6 +43,7 @@ interface ColdLaunchInjection {
42
43
  interface ColdLaunchInjections {
43
44
  injections: ColdLaunchInjection[];
44
45
  hasUIAbility: boolean;
46
+ hasAbility: boolean;
45
47
  }
46
48
 
47
49
  const COLD_LAUNCH_BRIDGE_IMPORT = '@guancecloud/ft_sdk/Index';
@@ -129,6 +131,37 @@ function needsUIAbilityModuleFallback(ts: TypeScriptApi, id: string, readSource?
129
131
  }
130
132
  }
131
133
 
134
+ /** An unresolved business class is not necessarily an Ability; trust explicit module entries. */
135
+ function isDeclaredAbilityEntry(ts: TypeScriptApi, id: string, readSource?: SourceReader): boolean {
136
+ const normalized = id.replace(/\\/g, '/');
137
+ const sourceIndex = normalized.lastIndexOf('/src/main/ets/');
138
+ if (sourceIndex < 0) return false;
139
+ const nodeRequire = require.main && typeof require.main.require === 'function'
140
+ ? require.main.require.bind(require.main) : require;
141
+ const path = nodeRequire('path');
142
+ const directory = normalized.slice(0, sourceIndex) + '/src/main';
143
+ try {
144
+ const manifest = directory + '/module.json5';
145
+ const code = readSource ? readSource(manifest) : nodeRequire('fs').readFileSync(manifest, 'utf8');
146
+ if (typeof code !== 'string') return false;
147
+ const source = ts.createSourceFile(manifest, `const __manifest = ${code}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
148
+ if (source.parseDiagnostics.length > 0 || source.statements.length !== 1 ||
149
+ !ts.isVariableStatement(source.statements[0])) return false;
150
+ const root = source.statements[0].declarationList.declarations[0].initializer;
151
+ const module = objectProperty(ts, root, 'module');
152
+ const entries = [objectProperty(ts, module, 'srcEntry')];
153
+ for (const key of ['abilities', 'extensionAbilities']) {
154
+ const list = objectProperty(ts, module, key);
155
+ if (list && ts.isArrayLiteralExpression(list)) {
156
+ list.elements.forEach((item: any) => entries.push(objectProperty(ts, item, 'srcEntry')));
157
+ }
158
+ }
159
+ return entries.some(entry => entry && ts.isStringLiteral(entry) && path.resolve(directory, entry.text) === path.resolve(id));
160
+ } catch (_) {
161
+ return false;
162
+ }
163
+ }
164
+
132
165
  function classifyAbilityBaseName(name: string): AbilityClassKind | null {
133
166
  if (name === 'InsightIntentEntryExecutor') {
134
167
  return 'intent';
@@ -504,9 +537,11 @@ function createFirstFrameRetryInjections(ts: TypeScriptApi, sourceFile: any, cod
504
537
 
505
538
  function createColdLaunchInjections(ts: TypeScriptApi, sourceFile: any, code: string,
506
539
  bridgeName: string, pluginVersion: string, identifiers: Set<string>,
507
- launchTypes: LaunchParameterTypes, readSource?: SourceReader): ColdLaunchInjections {
540
+ launchTypes: LaunchParameterTypes, readSource?: SourceReader, reportDiagnostic?: SourceDiagnosticReporter): ColdLaunchInjections {
508
541
  const injections: ColdLaunchInjection[] = [];
509
542
  let hasUIAbility = false;
543
+ let hasAbility = false;
544
+ const declaredEntry = isDeclaredAbilityEntry(ts, sourceFile.fileName, readSource);
510
545
  const resolve = createLexicalResolver(ts, sourceFile);
511
546
  const resolveClass = createAbilityClassResolver(ts, sourceFile, readSource);
512
547
  const pluginVersionArgument = pluginVersion.length > 0 ? `, ${JSON.stringify(pluginVersion)}` : '';
@@ -555,8 +590,14 @@ function createColdLaunchInjections(ts: TypeScriptApi, sourceFile: any, code: st
555
590
  const classification = resolveClass(node);
556
591
  const kind = classification.kind;
557
592
  if (!kind) {
593
+ if (declaredEntry && (node.modifiers || []).some((modifier: any) => modifier.kind === ts.SyntaxKind.DefaultKeyword)) {
594
+ reportSourceDiagnostic(sourceFile, node.getStart(sourceFile), 'unresolved-ability-base',
595
+ 'This module entry has no resolvable platform Ability/AbilityStage/InsightIntent base; automatic lifecycle instrumentation was skipped.',
596
+ reportDiagnostic);
597
+ }
558
598
  return;
559
599
  }
600
+ hasAbility = true;
560
601
  if (kind === 'ui') {
561
602
  hasUIAbility = true;
562
603
  }
@@ -572,7 +613,30 @@ function createColdLaunchInjections(ts: TypeScriptApi, sourceFile: any, code: st
572
613
  if (name === 'onBackground') hasOnBackground = true;
573
614
  if (name === 'onNewWant') hasOnNewWant = true;
574
615
  const member = callableMember(ts, declaration);
575
- if (!member || !name) return;
616
+ if (!name) return;
617
+ const expected = kind === 'intent' ? name === 'onExecute' : kind === 'legacyIntent'
618
+ ? ['onExecuteInUIAbilityForegroundMode', 'onExecuteInUIAbilityBackgroundMode'].includes(name)
619
+ : name === 'onCreate' || (kind === 'ui' &&
620
+ ['onForeground', 'onBackground', 'onNewWant', 'onWindowStageCreate'].includes(name));
621
+ if (!member) {
622
+ if (expected && !node.members.some((other: any) => memberName(ts, other) === name && callableMember(ts, other))) {
623
+ reportSourceDiagnostic(sourceFile, declaration.getStart(sourceFile), 'unsupported-lifecycle',
624
+ `${name} is not a method or function property with an instrumentable body; its automatic hook was skipped.`, reportDiagnostic);
625
+ }
626
+ return;
627
+ }
628
+ if (kind === 'ui' && name === 'onCreate' && member.parameters.slice(0, 2).some((parameter: any) =>
629
+ !ts.isIdentifier(parameter.name) || parameter.dotDotDotToken)) {
630
+ reportSourceDiagnostic(sourceFile, declaration.getStart(sourceFile), 'unsupported-lifecycle-parameters',
631
+ 'onCreate uses destructured or rest parameters; automatic launch-reason capture was skipped.', reportDiagnostic);
632
+ return;
633
+ }
634
+ if (kind === 'ui' && name === 'onWindowStageCreate' && (member.parameters.length === 0 ||
635
+ !ts.isIdentifier(member.parameters[0].name) || member.parameters[0].dotDotDotToken)) {
636
+ reportSourceDiagnostic(sourceFile, declaration.getStart(sourceFile), 'unsupported-lifecycle-parameters',
637
+ 'onWindowStageCreate requires a named WindowStage parameter for automatic first-frame observation.', reportDiagnostic);
638
+ return;
639
+ }
576
640
  const indentation = methodIndentation(code, declaration);
577
641
  const block = ts.isBlock(member.body);
578
642
  const position = member.body.getStart(sourceFile) + (block ? 1 : 0);
@@ -721,7 +785,7 @@ function createColdLaunchInjections(ts: TypeScriptApi, sourceFile: any, code: st
721
785
  }
722
786
  }
723
787
  });
724
- return { injections, hasUIAbility };
788
+ return { injections, hasUIAbility, hasAbility };
725
789
  }
726
790
 
727
791
  function applyColdLaunchInjections(code: string, injections: ColdLaunchInjection[]): string {
@@ -743,7 +807,7 @@ function applyColdLaunchInjections(code: string, injections: ColdLaunchInjection
743
807
  }
744
808
 
745
809
  export function transformColdLaunchSource(ts: TypeScriptApi, id: string, code: string,
746
- readSource?: SourceReader): string {
810
+ readSource?: SourceReader, reportDiagnostic?: SourceDiagnosticReporter): string {
747
811
  if (!isApplicationArkTsSource(id) || code.includes(COLD_LAUNCH_TRANSFORM_MARKER) ||
748
812
  !hasRuntimeDependency(ts, id, '@guancecloud/ft_sdk', readSource)) {
749
813
  return code;
@@ -757,8 +821,13 @@ export function transformColdLaunchSource(ts: TypeScriptApi, id: string, code: s
757
821
  needed: false
758
822
  };
759
823
  const version = getPluginVersion();
824
+ const diagnostics: SourceTransformDiagnostic[] = [];
760
825
  const result = createColdLaunchInjections(ts, sourceFile, code, bridgeName, version, identifiers,
761
- launchTypes, readSource);
826
+ launchTypes, readSource, diagnostic => diagnostics.push(diagnostic));
827
+ if (result.hasAbility || diagnostics.length > 0) {
828
+ if (reportSourceParseError(ts, parseApplicationSource(ts, id, code), reportDiagnostic)) return code;
829
+ diagnostics.forEach(diagnostic => reportDiagnostic?.(diagnostic));
830
+ }
762
831
  const injections = result.injections;
763
832
  const moduleFallback = result.hasUIAbility && needsUIAbilityModuleFallback(ts, id, readSource);
764
833
  if (injections.length === 0 && !moduleFallback) {
@@ -7,7 +7,8 @@
7
7
  * never changed.
8
8
  */
9
9
 
10
- import { hasRuntimeDependency, SourceReader } from './ft-transform-context';
10
+ import { hasRuntimeDependency, SourceReader, SourceDiagnosticReporter, parseApplicationSource,
11
+ reportSourceDiagnostic, reportSourceParseError } from './ft-transform-context';
11
12
  import { createLexicalResolver } from './ft-lexical-scope';
12
13
  import { allocateName, collectIdentifierNames } from './ft-generated-names';
13
14
 
@@ -94,16 +95,14 @@ function needsStatementSeparator(ts: TypeScriptApi, node: any): boolean {
94
95
  ts.isCaseClause(container) || ts.isDefaultClause(container);
95
96
  }
96
97
 
97
- function collectCandidates(ts: TypeScriptApi, sourceFile: any, code: string): TransformCandidate[] {
98
+ function collectCandidates(ts: TypeScriptApi, sourceFile: any, code: string,
99
+ unsupported: (node: any, message: string) => void): TransformCandidate[] {
98
100
  const candidates: TransformCandidate[] = [];
99
101
  const hiLogBindings = collectHiLogBindings(ts, sourceFile);
100
102
  const resolve = createLexicalResolver(ts, sourceFile);
101
103
 
102
104
  visit(ts, sourceFile, (node: any) => {
103
- // Retain optional calls verbatim so their receiver/method short-circuit also
104
- // controls argument evaluation. Nested ordinary calls remain eligible.
105
- if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) ||
106
- (ts.isOptionalChain && ts.isOptionalChain(node))) {
105
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) {
107
106
  return;
108
107
  }
109
108
  const delegate = node.expression.expression;
@@ -112,10 +111,20 @@ function collectCandidates(ts: TypeScriptApi, sourceFile: any, code: string): Tr
112
111
  }
113
112
  const delegateName = delegate.text;
114
113
  const level = node.expression.name.text;
114
+ const isConsole = delegateName === 'console' && !resolve(delegate, delegateName) && CONSOLE_LEVELS.includes(level);
115
+ const isHiLog = hiLogBindings.has(delegateName) &&
116
+ resolve(delegate, delegateName) === hiLogBindings.get(delegateName) && HILOG_LEVELS.includes(level);
117
+ if (!isConsole && !isHiLog) return;
118
+ // Preserve short-circuit semantics instead of transforming optional calls.
119
+ if (ts.isOptionalChain && ts.isOptionalChain(node)) {
120
+ unsupported(node, 'Optional Console/HiLog calls are left unchanged and are not automatically collected.');
121
+ return;
122
+ }
115
123
  const start = node.getStart(sourceFile);
116
124
  const end = node.end;
117
125
  const calleeEnd = node.expression.end;
118
126
  if (start < 0 || end > code.length || calleeEnd < start || calleeEnd > end) {
127
+ unsupported(node, 'The compiler returned an invalid source range for this logging call.');
119
128
  return;
120
129
  }
121
130
 
@@ -125,15 +134,16 @@ function collectCandidates(ts: TypeScriptApi, sourceFile: any, code: string): Tr
125
134
  spread: ts.isSpreadElement(argument)
126
135
  }));
127
136
  const statementSeparator = needsStatementSeparator(ts, node);
128
- if (delegateName === 'console' && !resolve(delegate, delegateName) && CONSOLE_LEVELS.includes(level)) {
137
+ if (isConsole) {
129
138
  candidates.push({ start, end, calleeEnd, delegate: delegateName, level, source: 'console', callArguments,
130
139
  needsStatementSeparator: statementSeparator });
131
140
  return;
132
141
  }
133
- if (hiLogBindings.has(delegateName) && resolve(delegate, delegateName) === hiLogBindings.get(delegateName) &&
134
- HILOG_LEVELS.includes(level) && (callArguments.length >= 3 || callArguments.some(argument => argument.spread))) {
142
+ if (callArguments.length >= 3 || callArguments.some(argument => argument.spread)) {
135
143
  candidates.push({ start, end, calleeEnd, delegate: delegateName, level, source: 'hilog', callArguments,
136
144
  needsStatementSeparator: statementSeparator });
145
+ } else {
146
+ unsupported(node, 'HiLog collection requires domain, tag and format arguments, or a spread argument supplying them.');
137
147
  }
138
148
  });
139
149
  return candidates;
@@ -210,13 +220,19 @@ function findImportInsertionOffset(ts: TypeScriptApi, sourceFile: any): number {
210
220
  return offset;
211
221
  }
212
222
 
213
- export function transformConsoleLogSource(ts: TypeScriptApi, id: string, code: string, readSource?: SourceReader): string {
223
+ export function transformConsoleLogSource(ts: TypeScriptApi, id: string, code: string, readSource?: SourceReader,
224
+ reportDiagnostic?: SourceDiagnosticReporter): string {
214
225
  if (!isApplicationArkTsSource(id) || code.includes(TRANSFORM_MARKER) ||
215
226
  !hasRuntimeDependency(ts, id, '@guancecloud/ft_sdk', readSource)) {
216
227
  return code;
217
228
  }
218
229
  const sourceFile = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
219
- const candidates = collectCandidates(ts, sourceFile, code);
230
+ const unsupported: Array<{ node: any; message: string }> = [];
231
+ const candidates = collectCandidates(ts, sourceFile, code, (node, message) => unsupported.push({ node, message }));
232
+ if (candidates.length === 0 && unsupported.length === 0) return code;
233
+ if (reportSourceParseError(ts, parseApplicationSource(ts, id, code), reportDiagnostic)) return code;
234
+ unsupported.forEach(item => reportSourceDiagnostic(sourceFile, item.node.getStart(sourceFile),
235
+ 'unsupported-log-call', item.message, reportDiagnostic));
220
236
  if (candidates.length === 0) {
221
237
  return code;
222
238
  }
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  import { isApplicationArkTsSource } from './ft-console-log-transform';
11
- import { hasRuntimeDependency, SourceReader } from './ft-transform-context';
11
+ import { hasRuntimeDependency, SourceReader, SourceDiagnosticReporter, parseApplicationSource,
12
+ reportSourceDiagnostic, reportSourceParseError } from './ft-transform-context';
12
13
  import { allocateName as selectUniqueName } from './ft-generated-names';
13
14
 
14
15
  type TypeScriptApi = any;
@@ -35,6 +36,7 @@ interface RootExpression {
35
36
  }
36
37
 
37
38
  interface EntryStruct {
39
+ node: any;
38
40
  bodyEnd: number;
39
41
  methods: Map<string, MethodRange>;
40
42
  }
@@ -86,6 +88,19 @@ function findImportInsertionOffset(ts: TypeScriptApi, id: string, code: string):
86
88
  return offset;
87
89
  }
88
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
+
89
104
  /** Structs, decorators and members must come from the ArkTS parser, never text matches. */
90
105
  function collectEntryStructs(ts: TypeScriptApi, source: any): EntryStruct[] {
91
106
  const entries: EntryStruct[] = [];
@@ -106,7 +121,7 @@ function collectEntryStructs(ts: TypeScriptApi, source: any): EntryStruct[] {
106
121
  bodyStart: member.body.getStart(source), bodyEnd: member.body.end - 1 });
107
122
  }
108
123
  });
109
- entries.push({ bodyEnd: node.end - 1, methods });
124
+ entries.push({ node, bodyEnd: node.end - 1, methods });
110
125
  });
111
126
  return entries;
112
127
  }
@@ -250,33 +265,31 @@ function addLifecycleInjection(
250
265
  * Adds an opaque root ID and page lifecycle registration to every @Entry page.
251
266
  * ArkTS nodes locate real decorators, struct members and component expressions.
252
267
  */
253
- export function transformSessionReplaySource(ts: TypeScriptApi, id: string, code: string, readSource?: SourceReader): string {
268
+ export function transformSessionReplaySource(ts: TypeScriptApi, id: string, code: string, readSource?: SourceReader,
269
+ reportDiagnostic?: SourceDiagnosticReporter): string {
254
270
  if (!isApplicationArkTsSource(id) || code.includes(REPLAY_ROOT_TRANSFORM_MARKER) ||
255
271
  !hasRuntimeDependency(ts, id, '@guancecloud/ft_sdk', readSource) ||
256
272
  !hasRuntimeDependency(ts, id, REPLAY_BRIDGE_IMPORT, readSource)) {
257
273
  return code;
258
274
  }
259
275
 
260
- if (typeof ts.isStructDeclaration !== 'function' || typeof ts.isEtsComponentExpression !== 'function') return code;
261
- // The parser needs component names to recognize declarative child blocks.
262
- // This superset configures parsing only; eligibility is decided by AST nodes.
263
- const components = Array.from(collectIdentifiers(code));
264
- const source = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.ETS,
265
- { ets: {
266
- components,
267
- render: { method: ['build', 'pageTransition'], decorator: ['Builder', 'LocalBuilder'] },
268
- extend: { decorator: ['Extend', 'AnimatableExtend'], components: components.map(name =>
269
- ({ name, type: `${name}Attribute`, instance: `${name}Instance` })) },
270
- styles: { decorator: 'Styles', component: { name: 'Common', type: 'T', instance: 'CommonInstance' }, property: 'stateStyles' },
271
- concurrent: { decorator: 'Concurrent' },
272
- customComponent: 'CustomComponent',
273
- syntaxComponents: { paramsUICallback: ['ForEach', 'LazyForEach'], attrUICallback: [{ name: 'Repeat', attributes: ['each', 'template'] }] }
274
- } });
275
- if (source.parseDiagnostics.length > 0) return code;
276
- const entries = collectEntryStructs(ts, source);
277
- if (entries.length === 0) {
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.');
278
287
  return code;
279
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;
280
293
 
281
294
  const identifiers = collectIdentifiers(code);
282
295
  const bridgeName = selectUniqueName(identifiers, '__ftSessionReplayAutoRootBridge');
@@ -287,10 +300,15 @@ export function transformSessionReplaySource(ts: TypeScriptApi, id: string, code
287
300
  entries.forEach((entry: EntryStruct): void => {
288
301
  const buildMethod = entry.methods.get('build');
289
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.');
290
305
  return;
291
306
  }
292
307
  const root = findRootExpression(ts, source, code, buildMethod);
293
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.');
294
312
  return;
295
313
  }
296
314
  const indentation = lineIndentation(code, buildMethod.nameStart);
@@ -14,6 +14,7 @@ import {
14
14
  import { transformColdLaunchSource } from './ft-cold-launch-transform';
15
15
  import { transformSessionReplaySource } from './ft-session-replay-transform';
16
16
  import { transformWebReplaySource } from './ft-web-replay-transform';
17
+ import { SourceDiagnosticReporter, SourceTransformDiagnostic } from './ft-transform-context';
17
18
 
18
19
  declare const require: {
19
20
  main: {
@@ -23,7 +24,8 @@ declare const require: {
23
24
 
24
25
  type TypeScriptApi = any;
25
26
  type SourceReader = (fileName: string) => string | undefined;
26
- type SourceTransform = (ts: TypeScriptApi, fileName: string, code: string, readSource?: SourceReader) => string;
27
+ type SourceTransform = (ts: TypeScriptApi, fileName: string, code: string, readSource?: SourceReader,
28
+ reportDiagnostic?: SourceDiagnosticReporter) => string;
27
29
 
28
30
  interface CompilePluginContext {
29
31
  share: {
@@ -35,6 +37,8 @@ interface CompilePluginContext {
35
37
 
36
38
  interface SourceTransformDefinition {
37
39
  id: string;
40
+ prefix: string;
41
+ feature: string;
38
42
  transform: SourceTransform;
39
43
  }
40
44
 
@@ -43,14 +47,15 @@ interface SharedSourceTransformRegistry {
43
47
  buffer: any;
44
48
  transforms: Map<string, (fileName: string, code: string) => string>;
45
49
  owners: Map<object, Map<string, (fileName: string, code: string) => string>>;
50
+ reportedDiagnostics: Set<string>;
46
51
  }
47
52
 
48
53
  const SOURCE_TRANSFORM_REGISTRY_KEY = '__guanceHvigorSourceTransformRegistry';
49
54
  const SOURCE_TRANSFORMS: SourceTransformDefinition[] = [
50
- { id: 'guance-console-log-transform', transform: transformConsoleLogSource },
51
- { id: 'guance-cold-launch-transform', transform: transformColdLaunchSource },
52
- { id: 'guance-session-replay-root-transform', transform: transformSessionReplaySource },
53
- { id: 'guance-web-replay-transform', transform: transformWebReplaySource }
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 }
54
59
  ];
55
60
 
56
61
  const registrations: Array<{ fileSystem: any; owner: object; context: CompilePluginContext }> = [];
@@ -60,6 +65,44 @@ function refreshTransforms(registry: SharedSourceTransformRegistry): void {
60
65
  registry.owners.forEach(transforms => transforms.forEach((transform, id) => registry.transforms.set(id, transform)));
61
66
  }
62
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
+ }
105
+
63
106
  function installSourceReader(context: CompilePluginContext): void {
64
107
  const typeScriptPath = `${context.share.projectConfig.etsLoaderPath}/node_modules/typescript`;
65
108
  const ts = require.main.require(typeScriptPath);
@@ -70,7 +113,8 @@ function installSourceReader(context: CompilePluginContext): void {
70
113
  originalReadFile: fileSystem.readFileSync as (fileName: string, options?: any) => any,
71
114
  buffer: require.main.require('buffer'),
72
115
  transforms: new Map<string, (fileName: string, code: string) => string>(),
73
- owners: new Map()
116
+ owners: new Map(),
117
+ reportedDiagnostics: new Set()
74
118
  };
75
119
  fileSystem[SOURCE_TRANSFORM_REGISTRY_KEY] = registry;
76
120
  fileSystem.readFileSync = (fileName: string, options?: any): any => {
@@ -93,11 +137,33 @@ function installSourceReader(context: CompilePluginContext): void {
93
137
  return undefined;
94
138
  }
95
139
  };
140
+ // A daemon may retain a registry installed by an earlier plugin version.
141
+ registry.reportedDiagnostics ??= new Set();
96
142
  const owner = {};
97
143
  const transforms = new Map<string, (fileName: string, code: string) => string>();
98
144
  SOURCE_TRANSFORMS.forEach((definition: SourceTransformDefinition) => {
99
- transforms.set(definition.id, (fileName: string, code: string) =>
100
- definition.transform(ts, fileName, code, readSource));
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
+ });
101
167
  });
102
168
  registry.owners.set(owner, transforms);
103
169
  refreshTransforms(registry);
@@ -3,6 +3,61 @@ declare const require: any;
3
3
 
4
4
  export type SourceReader = (fileName: string) => string | undefined;
5
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
+
6
61
  export function objectProperty(ts: any, object: any, name: string): any | undefined {
7
62
  if (!object || !ts.isObjectLiteralExpression(object)) return undefined;
8
63
  const matches = object.properties.filter((item: any) => ts.isPropertyAssignment(item) &&
@@ -1,6 +1,7 @@
1
1
  /** Associate every native Web with its controller without replacing application IDs or callbacks. */
2
2
  import { isApplicationArkTsSource } from './ft-console-log-transform';
3
- import { hasRuntimeDependency, SourceReader } from './ft-transform-context';
3
+ import { hasRuntimeDependency, SourceReader, SourceDiagnosticReporter, parseApplicationSource,
4
+ reportSourceDiagnostic, reportSourceParseError } from './ft-transform-context';
4
5
  import { createLexicalResolver } from './ft-lexical-scope';
5
6
  import { allocateName, collectIdentifierNames } from './ft-generated-names';
6
7
 
@@ -10,7 +11,8 @@ interface Injection { position: number; content: string; }
10
11
  const MARKER = '/* guance-web-replay-transform:instrumented */';
11
12
  const SDK_IMPORT = '@guancecloud/ft_sdk/Index';
12
13
 
13
- export function transformWebReplaySource(ts: TypeScriptApi, id: string, code: string, readSource?: SourceReader): string {
14
+ export function transformWebReplaySource(ts: TypeScriptApi, id: string, code: string, readSource?: SourceReader,
15
+ reportDiagnostic?: SourceDiagnosticReporter): string {
14
16
  if (!isApplicationArkTsSource(id) || code.includes(MARKER) ||
15
17
  !hasRuntimeDependency(ts, id, '@guancecloud/ft_sdk', readSource) ||
16
18
  !hasRuntimeDependency(ts, id, '@guancecloud/ft_session_replay', readSource)) return code;
@@ -18,16 +20,24 @@ export function transformWebReplaySource(ts: TypeScriptApi, id: string, code: st
18
20
  const source = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.ETS ?? ts.ScriptKind.TS);
19
21
  const identifiers = collectIdentifierNames(ts, source);
20
22
  const constructors: any[] = [];
23
+ const unsupported: any[] = [];
21
24
  const resolve = createLexicalResolver(ts, source);
22
25
  let importEnd = 0;
23
26
  const visit = (node: any): void => {
24
27
  if (ts.isImportDeclaration(node)) importEnd = Math.max(importEnd, node.end);
25
28
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'Web' &&
26
- node.arguments.length === 1 && !ts.isSpreadElement(node.arguments[0]) &&
27
- !resolve(node.expression, 'Web')) constructors.push(node);
29
+ !resolve(node.expression, 'Web')) {
30
+ if (node.arguments.length === 1 && !ts.isSpreadElement(node.arguments[0]) &&
31
+ !(ts.isOptionalChain && ts.isOptionalChain(node))) constructors.push(node);
32
+ else unsupported.push(node);
33
+ }
28
34
  ts.forEachChild(node, visit);
29
35
  };
30
36
  visit(source);
37
+ if (constructors.length === 0 && unsupported.length === 0) return code;
38
+ if (reportSourceParseError(ts, parseApplicationSource(ts, id, code), reportDiagnostic)) return code;
39
+ unsupported.forEach(node => reportSourceDiagnostic(source, node.getStart(source), 'unsupported-web-call',
40
+ 'Automatic ArkWeb binding requires a non-optional Web(options) call with exactly one non-spread argument.', reportDiagnostic));
31
41
  if (constructors.length === 0) return code;
32
42
 
33
43
  const bridge = allocateName(identifiers, '__ftWebReplayBridge');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudcare/hvigor-ohos-plugin",
3
- "version": "0.1.2-alpha.1",
3
+ "version": "0.1.2-alpha.2",
4
4
  "description": "Guance HarmonyOS Hvigor plugin for console, cold-launch, Session Replay page roots, and Web controller binding.",
5
5
  "main": "index.js",
6
6
  "files": [