forge lint cannot see a requestJira call whose path is held in a variable, so a missing scope in that call is never reported and the run ends with “No issues found.”
In 99853 remie hit An unexpected error occurred when fetching an auth token from @forge/bridge, posted a clean forge lint in reply to the suggestion that it was scopes, and it turned out to be scopes anyway. I went to find out what a clean lint actually rules out. I installed @forge/cli 13.3.0 and read the @forge/lint it pins, version 6.1.0, then ran that package’s exported lint() over a scratch app.
The blind spot
@forge/bridge takes a plain string path, so the only difference between these two calls is where the string lives. The manifest declares no scopes at all:
import { requestJira } from '@forge/bridge';
const PATH = '/rest/api/3/issue';
export const seen = () => requestJira('/rest/api/3/issue', { method: 'POST', body: '{}' });
export const unseen = () => requestJira(PATH, { method: 'POST', body: '{}' });
5:40 error Jira endpoint: POST /rest/api/3/issue requires "write:jira-work" scope permission-scope-required
X 1 issue (1 error, 0 warnings, 0 approvals)
One error, for line 5. Line 6 is the same call to the same endpoint and it is not mentioned.
On the backend @forge/api makes you pass a route, so the shapes differ but the outcome does not:
import api, { route } from '@forge/api';
const ISSUE = route`/rest/api/3/issue`;
const PATH = '/rest/api/3/issue';
const OPTS = { method: 'POST', body: '{}' };
export const seen = () => api.asApp().requestJira(route`/rest/api/3/issue`, { method: 'POST', body: '{}' });
export const hoist = () => api.asApp().requestJira(ISSUE, { method: 'POST', body: '{}' });
export const wrap = () => api.asApp().requestJira(route`${PATH}`, { method: 'POST', body: '{}' });
export const opts = () => api.asApp().requestJira(route`/rest/api/3/issue`, OPTS);
export const sprd = () => api.asApp().requestJira(route`/rest/api/3/issue`, { ...OPTS });
7:51 error Jira endpoint: POST /rest/api/3/issue requires "write:jira-work" scope permission-scope-required
X 1 issue (1 error, 0 warnings, 0 approvals)
Line 7 only. Hoisting the route into a const hides it. Wrapping the variable in a route hides it. Moving the options object into a const hides it, and spreading it back inline ({ ...OPTS }) passes the guard but finds no method key, so the call is filed as a GET.
The one that reports a wrong scope
With write:jira-work declared and read:jira-work not:
import api, { route } from '@forge/api';
const VERB = 'POST';
export const literal = () => api.asApp().requestJira(route`/rest/api/3/dashboard`, { method: 'POST', body: '{}' });
export const variable = () => api.asApp().requestJira(route`/rest/api/3/dashboard`, { method: VERB, body: '{}' });
6:54 error Jira endpoint: GET /rest/api/3/dashboard requires "read:jira-work" scope permission-scope-required
The literal POST is clean and correct. The identical POST with the verb in a variable is reported as a GET needing a read scope. --fix writes back the scope carried on the finding, so it would add read:jira-work here (I read the fixer, I did not run --fix end to end).
Why
@forge/lint 6.1.0, out/lint/linters/permission-linter/visitors/product-node-visitor.js:
if ((optionsNode && optionsNode.type !== AST_NODE_TYPES.ObjectExpression) ||
(endpointNode.type !== AST_NODE_TYPES.Literal &&
endpointNode.type !== AST_NODE_TYPES.TemplateLiteral &&
endpointNode.type !== AST_NODE_TYPES.TaggedTemplateExpression)) {
return;
}
const methodValue = methodProperty?.value.type === AST_NODE_TYPES.Literal &&
typeof methodProperty.value.value === 'string'
? methodProperty.value.value
: 'GET';
The visitor reads the AST node sitting in the argument position and does no symbol resolution, so an Identifier in either slot ends the analysis. A non-literal method becomes 'GET', and what happens next depends on the swagger. If the swagger has no GET for that path, no scope is required and nothing is reported at all: this is why /rest/api/3/issue with the verb in a variable stays silent even with an empty scopes list. If it does have a GET, you get the dashboard case above, a real error naming the wrong verb and the wrong scope.
Interpolation inside a route is allowed, but it is never resolved. Each ${...} is replaced by the text \S* and matched against the swagger path compiled to a regex that is anchored only at the end, after everything from the first ? is stripped. Verified in one run with no scopes declared: route`/rest/api/3/issue/${KEY}` is matched (PUT /rest/api/3/issue/{issueIdOrKey}), route`/rest/api/3/search?jql=${JQL}` is matched, and route`${BASE}/rest/api/3/issue` is matched, while route`${PATH}` , route`${BASE}/issue` and route`/rest/api/3/${THING}` are all silent. A ${...} is fine in a real {param} slot or in the query string, and fatal where it stands in for a literal segment.
Nothing downstream compensates. ProductNodeVisitor is the only visitor that emits product API calls, and forge lint runs linterMode: 'both', where the server-side half archives manifest.yml and only manifest.yml before calling getAppPreDeploymentCheck. No source file reaches it.
What this means in practice
Write the path and the method at the call site as literals wherever you can. If you cannot, a clean lint is not evidence about those calls, and the honest next step when a scope error appears at runtime is to grep for requestJira(, requestConfluence( and requestBitbucket( and read the argument shapes.
Everything above I ran in client-side mode over a real manifest and real source files, printed through the shipped reportLintResults. I byte-diffed the visitor against the newest prerelease, 6.2.0-next.9, and it is identical to the 6.1.0 that latest still points at, so this is not fixed on the next line. All of it is Jira and requestJira; I did not repeat it for Confluence or Bitbucket, which go through the same visitor.
Not checked: --fix end to end, forge deploy (it calls the same lint service, and with --build-tag it asks for server-side only, which by the above would skip this check), and which frontend files get linted at all. forge lint walks ./src plus the directories of resources it classifies as nativeUI, and I have not tested a Custom UI folder that is neither.
Atlassian’s wording is on the scopes page: lint will “assist you with adding missing scopes in your app”, and “if your app has missing permissions, these will be highlighted individually in the output”. The second sentence is the one that reads like a guarantee. The CLI reference for lint does not mention scopes at all.