Every analyzer's analyze() function receives three arguments:
controlTree, refGraph, and extraction. This page documents
every property they expose and shows how to use them.
Every analyzer is an ES module whose default export matches this shape:
export default {
name: "Human-readable name",
description: "What this analyzer detects",
resultKey: "camelCaseResultKey",
resultSchema: {
keys: [
{ key: "name", label: "Name", suggestedFormat: "name-copy" },
{ key: "type", label: "Type", suggestedFormat: "badge-info" },
{ key: "confidence", label: "Confidence", suggestedFormat: "badge-confidence" },
{ key: "message", label: "Details", suggestedFormat: "text-sm" },
{ key: "locations", label: "Locations", suggestedFormat: "locations" }
]
},
analyze(controlTree, refGraph, extraction) {
const results = [];
// ...inspect the three arguments and push result objects...
return results;
}
};
Always return an array (empty if nothing was found). Each result object should use the result shape described at the bottom of this page.
resultSchema declares every key a result row can have.
The config page uses it to populate the key dropdown and to auto-fill column labels and formats.
Every row must have exactly the keys listed — no conditional fields.
Use null when a key doesn't apply to a specific row. Missing or extra keys are
surfaced as warnings on the Test analyzer modal (they do not abort the run).
suggestedFormat accepts any value from the Format catalog:
text, text-sm, dim, codenumeric, percentagetruncate:80, truncate:120badge-info, badge-confidence (expects 'low'/'medium'/'high'), deadcode-typename-copy (bold name + copy button), locations (expects an array of {control, property, file, snippet}; shows the first 3 entries plus an … and N more counter)
The sample below is rendered live with the same function the report uses, driven by two fake result
rows for the resultSchema above. Every column uses the format noted on the header; the
second row's Locations cell shows the “…and N more” trailer that kicks in past
three entries.
| Name | Type | Confidence | Details | Locations |
|---|---|---|---|---|
| btnSubmit.OnSelect | empty-onselect | HIGH | btnSubmit.OnSelect is empty or a no-op. | btnSubmit > OnSelect > Src/HomeScreen.fx.yaml OnSelect: =Select(Parent) |
| lblHint.OnSelect | empty-onselect | MEDIUM | lblHint.OnSelect is set but contains only whitespace. | lblHint > OnSelect > Src/HomeScreen.fx.yaml OnSelect: = lblHintAlt > OnSelect > Src/DetailScreen.fx.yaml OnSelect: = lblFooter > OnSelect > Src/HomeScreen.fx.yaml OnSelect: =false ...and 1 more |
A unified hierarchy of the entire app: the App node, all screens, all component definitions, and every control inside them. Use this when you need to walk or query the structure of the app.
controlTree.screenscontrolTree.componentsallNodes with isComponentInstance === true.controlTree.allNodescontrolTree.nodeIndexMap<controlName, node> for O(1) lookup by name. Use this when you have a control
name from a formula and need its node.controlTree.appNodeformulas map holds OnStart,
OnError, Formulas (named formula definitions), etc. May be absent on malformed
apps — always null-check.controlTree.startScreenFormula string | nullStartScreen property, with the leading = stripped (e.g. If(User().Email = ..., HomeScreen, LoginScreen)). null when no StartScreen is declared.const buttons = controlTree.allNodes.filter(
node => node.baseType === 'Button' || node.baseType === 'ModernButton'
);
for (const screen of controlTree.screens) {
const descendants = countDescendants(screen);
if (descendants > 50) {
results.push({
name: screen.name,
type: 'screen-too-many-controls',
message: `Screen has ${descendants} controls — consider splitting it.`,
locations: [{ control: screen.name, property: 'definition', file: screen.filePath }],
confidence: 'medium'
});
}
}
function countDescendants(node) {
let n = node.children.length;
for (const child of node.children) n += countDescendants(child);
return n;
}
Every entry in allNodes, screens, components and
nodeIndex is a control node with the following properties:
name stringbtnSubmit).type stringFluentV8/Button).baseType stringButton, Gallery, Label). Produced by stripping everything after the first @ or /. Prefer this over type when matching control kinds.variant string | nullVariant: value from the YAML, when present (e.g. 'Primary'). null otherwise.isApp, isScreen, isComponent, isComponentInstance, isLocked booleanisComponent marks a component definition; isComponentInstance marks a component placed on a screen. isLocked is set on read-only template controls (e.g. gallery row templates).children Array<node>parent node | nullnull on the App node, screens, and top-level component definitions.formulas Map<string, string>= in YAML). Keys are property names like OnSelect, Text, Items; values are the formula source with the leading = stripped.properties Map<string, string>2 in YAML is stored as the string "2", and true is stored as "true". Parse with parseInt / parseFloat / equality checks as needed.screen string | nullnull for the App node and for component definitions.filePath string | nulllocations[].file. May be null for the App node.componentName string | nullnull everywhere else.group string | nullGroup: in YAML), when the control was placed into a design-time group. null otherwise.customProperties objectCustomProperties block from YAML, keyed by custom property name. Useful for linting a component's public surface.for (const node of controlTree.allNodes) {
if (!node.formulas.has('OnSelect')) continue;
const onSelect = node.formulas.get('OnSelect').trim();
if (onSelect === '' || onSelect === 'false' || onSelect === 'Select(Parent)') {
results.push({
name: node.name,
type: 'empty-onselect',
message: `${node.name}.OnSelect is empty or a no-op`,
locations: [{ control: node.name, property: 'OnSelect', file: node.filePath }],
confidence: 'high'
});
}
}
A cross-reference graph built on top of the control tree and raw extraction. Use it to answer questions like “is this control referenced anywhere?” or “is this variable ever read?”.
refGraph.referencedControlsMap<controlName, Array<{control, property, file, screen, refType, snippet}>> —
for each control name, the places it is referenced from. refType is one of
'Select()', 'Reset()', 'dot access', or 'identifier'.
Entries are de-duplicated by (control, property, refType). If a control is
missing from the map (or its list is empty), nothing in the app references it by name.refGraph.referencedScreensMap<screenName, Array<{control, property, file, screen, snippet}>> — screens
that are reached via Navigate(). Screens absent from this map and not equal to the
start screen are unreachable.refGraph.variablesReadSet<string> of variable names that are actually consumed by at least one formula
(not just assigned with Set()/UpdateContext()).refGraph.collectionsReadSet<string> of collection names that are read. A collection that is
Collect()ed but never read is not in this set.refGraph.namedFormulasReadSet<string> of named formulas (defined in App.Formulas) that are
referenced from at least one other formula.for (const node of controlTree.allNodes) {
if (node.isApp || node.isScreen || node.isComponent) continue;
const refs = refGraph.referencedControls.get(node.name);
if (refs && refs.length > 0) continue;
results.push({
name: node.name,
type: 'unused-control',
message: `${node.name} is not referenced by any formula.`,
locations: [{ control: node.name, property: 'definition', file: node.filePath }],
confidence: 'medium'
});
}
for (const [varName, writes] of extraction.variableWrites) {
if (refGraph.variablesRead.has(varName)) continue;
results.push({
name: varName,
type: 'dead-variable',
message: `Variable '${varName}' is set but never read.`,
locations: writes.map(w => ({
control: w.control, property: w.property, file: w.file, snippet: w.snippet
})),
confidence: 'high'
});
}
The raw result of parsing every Power Fx formula in the app. Each entry carries enough context
(control, property, file, often snippet) to point
directly at the offending line — feed these straight into the locations array of a
result.
Every location entry below shares the shape
{ control, property, file, screen, snippet } (except allFormulas, which
carries the full formula instead of a snippet, and
namedFormulaDefs, which has no snippet). snippet is a short
±20/+40-character window around the matched call, with newlines collapsed and …
markers where the surrounding formula was trimmed.
extraction.allFormulasArray<{control, property, file, screen, formula}> — every Power Fx formula found in
the app, with the leading = already stripped. Good starting point for text-based scans
(e.g. pattern matching across the whole codebase).extraction.variableWritesMap<varName, Array<{control, property, file, screen, snippet}>> — every
Set() / UpdateContext() call grouped by the variable being written.extraction.collectionWritesMap<colName, Array<{control, property, file, screen, snippet}>> — every
Collect() / ClearCollect() call grouped by collection name.extraction.navigateRefsMap<screenName, Array<{control, property, file, screen, snippet}>> — every
Navigate() call grouped by destination screen. Screens missing from this map are
unreachable by navigation (unless they are the start screen).extraction.selectRefsMap<controlName, Array<{control, property, file, screen, snippet}>> — every
Select(ControlName) call grouped by target.extraction.resetRefsselectRefs but for Reset() calls.extraction.dotAccessRefsControlName.Property dot access (e.g. TextInput1.Text).
The strongest signal that a control is actually used.extraction.namedFormulaDefsMap<formulaName, {control, property, file, screen}> — named formulas defined in
App.Formulas. One location per formula (not an array).extraction.allIdentifiersInFormulasSet<string> of every bare identifier that appears in any formula. Useful for
“is this name mentioned anywhere?” questions without crafting your own tokenizer.extraction.knownControlNamesSet<string> of every control name declared in the app. Paired with
allIdentifiersInFormulas, this lets you separate formula identifiers that refer to
real controls from identifiers that point at variables, collections, or functions.extraction.knownScreenNamesSet<string> of every screen name declared in the app.const startScreen = (controlTree.startScreenFormula || '').trim();
for (const screen of controlTree.screens) {
const navigatedTo = extraction.navigateRefs.has(screen.name);
const isStart = startScreen.includes(screen.name);
if (navigatedTo || isStart) continue;
results.push({
name: screen.name,
type: 'unreachable-screen',
message: `Screen '${screen.name}' is never navigated to and is not the start screen.`,
locations: [{ control: screen.name, property: 'definition', file: screen.filePath }],
confidence: 'medium'
});
}
const HEX_COLOR = /\bColor\.(RGBA|FromHex)\s*\(\s*["']?#[0-9a-f]{3,8}/i;
for (const { control, property, formula, file } of extraction.allFormulas) {
if (!HEX_COLOR.test(formula)) continue;
results.push({
name: `${control}.${property}`,
type: 'hardcoded-color',
message: `${control}.${property} uses a hardcoded color — consider a theme named formula.`,
locations: [{ control, property, file }],
confidence: 'low'
});
}
Each entry your analyze() pushes into the returned array should look like this. The UI
uses these fields to render issues, link back to source, and group findings.
{
name: "btnSubmit.OnSelect", // short identifier shown in the report
type: "empty-onselect", // category/slug — group related findings
message: "btnSubmit.OnSelect is empty or a no-op",
locations: [
{
control: "btnSubmit",
property: "OnSelect",
file: "Src/HomeScreen.fx.yaml",
snippet: "OnSelect: =Select(Parent)" // optional
}
],
confidence: "high" // 'high' | 'medium' | 'low' (optional)
}
analyze() is called synchronously. Don’t return a Promise —
the runner won’t await it, your results will be dropped, and the row count will be zero.
Stick to synchronous iteration over the three arguments.controlTree.appNode. Some malformed or partial
apps don’t have one.baseType over type when you just want to know
“is this a Button?” — type may include a namespace like
FluentV8/Button.nodeIndex for lookups instead of scanning allNodes
with a filter — it’s a Map and much faster.node.formulas holds Power Fx expressions
(with the leading = removed), node.properties holds literal scalars
stringified. A property set to =true lives in formulas;
a property set to just true lives in properties as the string
"true".controlTree.components
for the templates, filter allNodes by isComponentInstance for the
usages, and use the instance's componentName to link back to the definition.extraction already did. Scanning
allFormulas with regex for Set(, Collect(,
Navigate(, Select(, etc. is almost always a mistake — those calls
are already parsed into the dedicated maps, with snippets and per-location context.refGraph.referencedControls is de-duplicated per
(control, property, refType). If you need the raw unfiltered call sites, read
from extraction.selectRefs / resetRefs / dotAccessRefs
directly.null or
undefined.resultSchema.keys; use null for values that
don’t apply to a given row rather than omitting the key.snippet from extraction entries when building
locations; the UI uses it to show the exact offending line.