@herbertgao/pi-extensions 2026.9.14 → 2026.9.16
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/README.md +8 -3
- package/THIRD_PARTY_NOTICES.md +28 -2
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
- package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
- package/node_modules/@narumitw/pi-btw/package.json +4 -4
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
- package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
- package/node_modules/pi-jev-auto-mode/CHANGELOG.md +8 -0
- package/node_modules/pi-jev-auto-mode/README.md +7 -0
- package/node_modules/pi-jev-auto-mode/package.json +1 -1
- package/node_modules/pi-jev-auto-mode/src/extension.ts +21 -2
- package/node_modules/pi-jev-auto-mode/src/records.ts +68 -2
- package/node_modules/pi-jev-auto-mode/src/settings.ts +22 -0
- package/node_modules/pi-jev-auto-mode/src/ui.ts +10 -0
- package/node_modules/pi-lens/CHANGELOG.md +498 -0
- package/node_modules/pi-lens/README.md +45 -2
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
- package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
- package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
- package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
- package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
- package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
- package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
- package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
- package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
- package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
- package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
- package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
- package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
- package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
- package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
- package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
- package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
- package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
- package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
- package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
- package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
- package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
- package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
- package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
- package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
- package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
- package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
- package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
- package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
- package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
- package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
- package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
- package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
- package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
- package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
- package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
- package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
- package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
- package/node_modules/pi-lens/dist/index.js +5695 -3733
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
- package/node_modules/pi-lens/dist/mcp/server.js +33 -21
- package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
- package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
- package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
- package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
- package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
- package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
- package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
- package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
- package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
- package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
- package/node_modules/pi-lens/docs/adr/README.md +26 -0
- package/node_modules/pi-lens/docs/agent-tools.md +18 -1
- package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
- package/node_modules/pi-lens/docs/configuration.md +55 -1
- package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
- package/node_modules/pi-lens/docs/environment-variables.md +61 -4
- package/node_modules/pi-lens/docs/features.md +2 -2
- package/node_modules/pi-lens/docs/globalconfig.md +6 -1
- package/node_modules/pi-lens/docs/language-coverage.md +17 -0
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
- package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
- package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
- package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
- package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
- package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
- package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
- package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
- package/node_modules/pi-lens/docs/settings.md +12 -3
- package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
- package/node_modules/pi-lens/package.json +5 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
- package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
- package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
- package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
- package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
- package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
- package/node_modules/pi-mcp-adapter/README.md +12 -5
- package/node_modules/pi-mcp-adapter/config.ts +31 -8
- package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
- package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
- package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
- package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
- package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
- package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
- package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
- package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
- package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
- package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
- package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
- package/node_modules/pi-mcp-adapter/index.ts +37 -20
- package/node_modules/pi-mcp-adapter/init.ts +14 -15
- package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
- package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
- package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
- package/node_modules/pi-mcp-adapter/package.json +3 -2
- package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
- package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
- package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
- package/node_modules/pi-mcp-adapter/types.ts +2 -2
- package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
- package/node_modules/pi-mcp-adapter/utils.ts +11 -3
- package/node_modules/pi-multi-account/CHANGELOG.md +6 -0
- package/node_modules/pi-multi-account/README.md +15 -1
- package/node_modules/pi-multi-account/index.ts +295 -41
- package/node_modules/pi-multi-account/package.json +1 -1
- package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
- package/node_modules/pi-next-cue/LICENSE +21 -0
- package/node_modules/pi-next-cue/README.md +81 -0
- package/node_modules/pi-next-cue/index.ts +358 -0
- package/node_modules/pi-next-cue/package.json +28 -0
- package/package.json +10 -7
|
@@ -7,6 +7,7 @@ import { logLatency } from "./latency-logger.js";
|
|
|
7
7
|
import { touchCoverageGap } from "./lsp/diagnostic-binding.js";
|
|
8
8
|
import { loadLspService } from "./lsp-lazy.js";
|
|
9
9
|
import { contentHash, createWarmIpcLineReader, diagnosticsIpcPathForCwd, requestWarmCodeActions, requestWarmDiagnostics, WARM_CODE_ACTION_LOOKUP_LIMIT, WARM_DIAGNOSTICS_SCHEMA_VERSION, } from "./mcp/ipc.js";
|
|
10
|
+
import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
|
|
10
11
|
import { normalizeFilePath } from "./path-utils.js";
|
|
11
12
|
const state = { local: true, servedDiagnosticHashes: new Map() };
|
|
12
13
|
function record(event, cwd, reason, pid, source) {
|
|
@@ -138,6 +139,33 @@ function startServer(cwd) {
|
|
|
138
139
|
}
|
|
139
140
|
const server = net.createServer((socket) => {
|
|
140
141
|
socket.setEncoding("utf8");
|
|
142
|
+
// #3389: an ACCEPTED socket with no `error` listener rethrows, and
|
|
143
|
+
// `server.on("error")` below covers only the listener. A client that walks
|
|
144
|
+
// away while this incumbent is answering — every `requestWarmDiagnostics`
|
|
145
|
+
// timeout, schema refusal and validation refusal destroys its socket
|
|
146
|
+
// (`clients/mcp/ipc.ts:312`) — therefore turned a routine
|
|
147
|
+
// `read ECONNRESET` into an uncaught exception in THIS host, the same
|
|
148
|
+
// ending #3375 closed for an unbounded `data` handler. The handler does NOT
|
|
149
|
+
// tear the connection down: a real system failure has already destroyed the
|
|
150
|
+
// stream before this runs (measured: `destroyed` is true at entry), and an
|
|
151
|
+
// `error` emitted with no errno leaves a socket that is still answering
|
|
152
|
+
// (review round 1) — destroying that one would drop a live request. So the
|
|
153
|
+
// handler exists to keep the event catchable and to leave the peer's
|
|
154
|
+
// behavior visible in the ledger.
|
|
155
|
+
socket.on("error", (failure) => {
|
|
156
|
+
incrementDegradationCount({
|
|
157
|
+
// Subject is the errno, so one peer's repeated resets stay one row.
|
|
158
|
+
// No `?? "unknown"` fallback: `normalizeForLedger`
|
|
159
|
+
// (`clients/ledger-bounds.ts:29`) already maps a missing value to
|
|
160
|
+
// `"unknown"`, and a second copy of that rule here was a branch no
|
|
161
|
+
// mutation could red (#3389 review round 1, F3395-01) — an error
|
|
162
|
+
// emitted with no `code` is pinned end-to-end in
|
|
163
|
+
// `tests/clients/warm-attach-socket-error.test.ts`.
|
|
164
|
+
kind: "warm-attach-socket-error",
|
|
165
|
+
subject: failure.code,
|
|
166
|
+
reason: String(failure),
|
|
167
|
+
});
|
|
168
|
+
});
|
|
141
169
|
// One-shot per connection (#1219 family): the same defect shape as the
|
|
142
170
|
// MCP warm socket — clients write exactly one request and read one
|
|
143
171
|
// reply, so a handler that kept re-reading the same buffered line
|
|
@@ -153,6 +181,11 @@ function startServer(cwd) {
|
|
|
153
181
|
socket.end(`${JSON.stringify({ error: String(error) })}\n`);
|
|
154
182
|
}
|
|
155
183
|
})();
|
|
184
|
+
}, {
|
|
185
|
+
label: "warm-attach-server",
|
|
186
|
+
// #3383: an unterminated request line is answered like any other
|
|
187
|
+
// unusable request rather than buffered without a ceiling.
|
|
188
|
+
onOverflow: () => socket.end(`${JSON.stringify({ error: "warm request line exceeded the framing limit" })}\n`),
|
|
156
189
|
}));
|
|
157
190
|
});
|
|
158
191
|
server.on("error", (error) => {
|
|
@@ -191,6 +224,20 @@ export async function tryWarmAttachedDiagnostics(file, content, timeoutMs, sourc
|
|
|
191
224
|
}
|
|
192
225
|
const result = await requestWarmDiagnostics(state.cwd, state.incumbentPid, file, content, timeoutMs);
|
|
193
226
|
if (!result.available) {
|
|
227
|
+
// #3255 H1: a confirmed-alive incumbent whose endpoint has no listener is
|
|
228
|
+
// the upgrade-stranded shape — narrowing the workspace-id case fold
|
|
229
|
+
// renamed the socket, so a peer that registered before the upgrade is
|
|
230
|
+
// still serving the previous name. `promoteToLocal` alone left that in the
|
|
231
|
+
// latency log only; this is the entry `/lens-perf` renders. Bounded twice
|
|
232
|
+
// over: once per kind/subject by the ledger, and once per session by
|
|
233
|
+
// `promoteToLocal`'s latch, which returns early on every later call.
|
|
234
|
+
if (result.reason === "no-listener") {
|
|
235
|
+
recordDegradationOnce({
|
|
236
|
+
kind: "warm-ipc-endpoint-missing",
|
|
237
|
+
subject: diagnosticsIpcPathForCwd(state.cwd, state.incumbentPid),
|
|
238
|
+
reason: "the registry confirms this incumbent is alive but nothing is listening on its derived endpoint — if pi-lens was upgraded while it was running, restart it",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
194
241
|
promoteToLocal(result.reason);
|
|
195
242
|
}
|
|
196
243
|
else {
|
|
@@ -231,6 +278,18 @@ export function _resetWarmAttachForTests() {
|
|
|
231
278
|
state.local = true;
|
|
232
279
|
state.servedDiagnosticHashes.clear();
|
|
233
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* The live diagnostics server, exposed for tests (#3389 review round 1,
|
|
283
|
+
* F3395-01). The `error` handler on an ACCEPTED socket has exactly one input
|
|
284
|
+
* channel — an `error` event on that socket — and a test cannot reach the
|
|
285
|
+
* socket any other way: it is created by `net.createServer` inside the closure
|
|
286
|
+
* above, and Node's ESM namespace for `node:net` is not configurable, so the
|
|
287
|
+
* factory cannot be wrapped from a test either. A test observes the server's
|
|
288
|
+
* own `connection` event through this accessor; nothing in production reads it.
|
|
289
|
+
*/
|
|
290
|
+
export function _warmAttachServerForTests() {
|
|
291
|
+
return state.server;
|
|
292
|
+
}
|
|
234
293
|
export function _setWarmAttachForTests(cwd, incumbentPid) {
|
|
235
294
|
state.cwd = path.resolve(cwd);
|
|
236
295
|
state.incumbentPid = incumbentPid;
|
|
@@ -13,7 +13,7 @@ import { normalizeMessage } from "./finding-identity.js";
|
|
|
13
13
|
import { freshnessFromMtime } from "./freshness.js";
|
|
14
14
|
import { PAST_EOF_STALE_MARKER } from "./diagnostic-line-freshness.js";
|
|
15
15
|
import { STALE_LINE_MARKER } from "./stale-marker.js";
|
|
16
|
-
import { anchorsForDiagnostic, applyDispositions, getDisposition, registerWidgetDispositionReconciler, } from "./diagnostic-dispositions.js";
|
|
16
|
+
import { anchorsForDiagnostic, applyDispositions, getDisposition, registerWidgetDispositionReconciler, strictAnchorSpan, strictAnchorSpansIn, } from "./diagnostic-dispositions.js";
|
|
17
17
|
import { recordDegradationOnce } from "./degradation-ledger.js";
|
|
18
18
|
/**
|
|
19
19
|
* Canonical key for the `files` map (and `diagnosticsWriteGuard`) — #1020.
|
|
@@ -174,8 +174,23 @@ export function reconcileWidgetDisposition(cwd, target, _disposition, content) {
|
|
|
174
174
|
if (!active.has(diagnostic) &&
|
|
175
175
|
(entry?.disposition === "false-positive" ||
|
|
176
176
|
entry?.disposition === "suppress")) {
|
|
177
|
+
// #3183: stamp the strict anchor's own SPAN so the row's lifetime can
|
|
178
|
+
// later ask the mark instead of the file's mtime. `source` is the content
|
|
179
|
+
// the anchors above were derived from, so the span is the one the mark
|
|
180
|
+
// itself binds to. Only the STRICT arm has a span — a weak `suppress`
|
|
181
|
+
// mark hashes no line — and re-tagging always re-derives it, so a stale
|
|
182
|
+
// span from an earlier tag can never survive into a new disposition.
|
|
183
|
+
const { anchorSpan: _previousSpan, ...base } = diagnostic;
|
|
184
|
+
const span = entry.disposition === "false-positive"
|
|
185
|
+
? strictAnchorSpan(source, diagnostic.line)
|
|
186
|
+
: undefined;
|
|
177
187
|
return [
|
|
178
|
-
{
|
|
188
|
+
{
|
|
189
|
+
...base,
|
|
190
|
+
disposition: entry.disposition,
|
|
191
|
+
flagged: false,
|
|
192
|
+
...(span === undefined ? {} : { anchorSpan: span }),
|
|
193
|
+
},
|
|
179
194
|
];
|
|
180
195
|
}
|
|
181
196
|
// #3158 retirement rule 3: a row the store holds ONLY because it was
|
|
@@ -186,7 +201,11 @@ export function reconcileWidgetDisposition(cwd, target, _disposition, content) {
|
|
|
186
201
|
// currently present.
|
|
187
202
|
if (diagnostic.suppressedRetained)
|
|
188
203
|
return [];
|
|
189
|
-
const { disposition: _disposition, flagged: _flagged,
|
|
204
|
+
const { disposition: _disposition, flagged: _flagged,
|
|
205
|
+
// #3183: a row that is no longer disposition-tagged has no mark to
|
|
206
|
+
// anchor, so its span goes with the tag — leaving it would exempt a LIVE
|
|
207
|
+
// row from the sweep's mtime gate.
|
|
208
|
+
anchorSpan: _anchorSpan, ...baseDiagnostic } = diagnostic;
|
|
190
209
|
return entry?.disposition === "flagged"
|
|
191
210
|
? [{ ...baseDiagnostic, flagged: true }]
|
|
192
211
|
: [baseDiagnostic];
|
|
@@ -538,6 +557,16 @@ function normalizeDiagnostics(filePath, diagnostics, observedTs) {
|
|
|
538
557
|
* file — so it would discriminate nothing. `normalizeMessage` is the shared
|
|
539
558
|
* derivation from `clients/finding-identity.ts` that the anchor uses, imported
|
|
540
559
|
* rather than re-spelled.
|
|
560
|
+
*
|
|
561
|
+
* #3183: this identity is OCCURRENCE-BLIND, and for a STRICT (`false-positive`)
|
|
562
|
+
* row that is not enough on its own. Two findings of one rule with identical
|
|
563
|
+
* messages on different lines collapse onto it, so a scan reporting only the
|
|
564
|
+
* UNMARKED one matched the marked row and replaced it — the chip lost a mark
|
|
565
|
+
* that was still applying. A collision is therefore resolved by the second half
|
|
566
|
+
* of {@link WidgetDiagnostic.anchorSpan}'s contract, in `retainSuppressedRows`:
|
|
567
|
+
* only a colliding incoming finding sitting ON the marked line is the same
|
|
568
|
+
* finding. That test, not this one, is what keeps the round-2 F2 case above
|
|
569
|
+
* closed — the marked line's TEXT moved, so its span still matches.
|
|
541
570
|
*/
|
|
542
571
|
function retentionIdentity(d) {
|
|
543
572
|
return JSON.stringify([
|
|
@@ -546,6 +575,39 @@ function retentionIdentity(d) {
|
|
|
546
575
|
normalizeMessage(d.message),
|
|
547
576
|
]);
|
|
548
577
|
}
|
|
578
|
+
/** `filePath`'s current content, or `undefined` when it cannot be read. */
|
|
579
|
+
function readContentOrUndefined(filePath) {
|
|
580
|
+
try {
|
|
581
|
+
return readFileSync(filePath, "utf8");
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// Only reached once this module has established there IS a span-carrying row
|
|
585
|
+
// to ask about, so the record is bounded by that population — and worth
|
|
586
|
+
// making: with no content both callers fall back to the pre-#3183 rules (the
|
|
587
|
+
// per-entry mtime gate, the coarse retention identity), which is the
|
|
588
|
+
// conservative direction but can retire a mark that still stands.
|
|
589
|
+
recordDegradationOnce({
|
|
590
|
+
kind: "widget-mark-anchor-unreadable",
|
|
591
|
+
subject: filePath,
|
|
592
|
+
reason: "the marked file could not be read, so its suppressed rows fall back to the file-mtime retirement gate and the footer chip may under-count it",
|
|
593
|
+
});
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* {@link strictAnchorSpansIn} over `filePath`'s current content (#3183), or
|
|
599
|
+
* `undefined` when it cannot be read.
|
|
600
|
+
*
|
|
601
|
+
* Synchronous in both callers, matching `applyCachedDispositions`
|
|
602
|
+
* (`tools/lens-diagnostics.ts`) — the seam that asks the very same question on
|
|
603
|
+
* the very same read path, with `statSync`/`readFileSync`. An async read here
|
|
604
|
+
* would add two unbounded awaits to a module a registered hook handler imports
|
|
605
|
+
* directly (`tests/config/hook-await-bounds.test.ts`) for no behavioural gain.
|
|
606
|
+
*/
|
|
607
|
+
function readAnchorSpans(filePath) {
|
|
608
|
+
const content = readContentOrUndefined(filePath);
|
|
609
|
+
return content === undefined ? undefined : strictAnchorSpansIn(content);
|
|
610
|
+
}
|
|
549
611
|
/**
|
|
550
612
|
* #3158: carry the record's disposition-tagged rows across a whole-replace
|
|
551
613
|
* write when the incoming set no longer reports them, so a `false-positive` /
|
|
@@ -564,8 +626,46 @@ function retainSuppressedRows(previous, incoming, filePath) {
|
|
|
564
626
|
const tagged = previous.filter((d) => d.disposition !== undefined);
|
|
565
627
|
if (tagged.length === 0)
|
|
566
628
|
return incoming;
|
|
567
|
-
const reported = new
|
|
568
|
-
const
|
|
629
|
+
const reported = new Map();
|
|
630
|
+
for (const d of incoming) {
|
|
631
|
+
const id = retentionIdentity(d);
|
|
632
|
+
const bucket = reported.get(id);
|
|
633
|
+
if (bucket)
|
|
634
|
+
bucket.push(d);
|
|
635
|
+
else
|
|
636
|
+
reported.set(id, [d]);
|
|
637
|
+
}
|
|
638
|
+
// #3183: the ONE file read on this path, and only for the state the coarse
|
|
639
|
+
// identity genuinely cannot resolve — a strict-anchored row whose identity an
|
|
640
|
+
// incoming finding collides with. Every other write (no marks at all, a mark
|
|
641
|
+
// with no collision, a weak `suppress` mark) stays zero-I/O, which is what
|
|
642
|
+
// keeps this off the per-edit `recordDiagnostics` cost.
|
|
643
|
+
const content = tagged.some((d) => d.anchorSpan !== undefined && reported.has(retentionIdentity(d)))
|
|
644
|
+
? readContentOrUndefined(filePath)
|
|
645
|
+
: undefined;
|
|
646
|
+
const spans = content === undefined ? undefined : strictAnchorSpansIn(content);
|
|
647
|
+
const retained = tagged.filter((d) => {
|
|
648
|
+
const collisions = reported.get(retentionIdentity(d));
|
|
649
|
+
// Nothing re-reported this finding, so retention rule 2 has nothing to say.
|
|
650
|
+
if (collisions === undefined)
|
|
651
|
+
return true;
|
|
652
|
+
// No span to ask (a weak `suppress` mark, or a row from an older build), or
|
|
653
|
+
// the file is unreadable: fall back to the pre-#3183 coarse rule. The safe
|
|
654
|
+
// direction is to UNDER-count, never to leave a phantom duplicate standing
|
|
655
|
+
// beside a live finding.
|
|
656
|
+
if (d.anchorSpan === undefined || spans === undefined)
|
|
657
|
+
return false;
|
|
658
|
+
// The mark stopped applying — its line is gone from the file — so the
|
|
659
|
+
// colliding finding IS this one, re-reported now that the filter no longer
|
|
660
|
+
// drops it (retirement rule 2, and the same verdict the sweep reaches).
|
|
661
|
+
if (!spans.has(d.anchorSpan))
|
|
662
|
+
return false;
|
|
663
|
+
// The mark still applies. Only a collision sitting ON the marked line is
|
|
664
|
+
// the same finding (its line number may have shifted under an edit — #3158
|
|
665
|
+
// round 2 F2); any other is a DIFFERENT occurrence this strict anchor never
|
|
666
|
+
// covered, and replacing the row with it loses a live mark (#3183).
|
|
667
|
+
return !collisions.some((i) => strictAnchorSpan(content, i.line) === d.anchorSpan);
|
|
668
|
+
});
|
|
569
669
|
if (retained.length > MAX_RETAINED_SUPPRESSED_PER_FILE) {
|
|
570
670
|
// Freshest observations win the cap. One record per FILE, never one per
|
|
571
671
|
// dropped row (AGENTS.md "bounded observability"): the chip under-counts
|
|
@@ -886,13 +986,24 @@ export async function reconcileStaleWidgetFiles() {
|
|
|
886
986
|
// at dispatch/integration.ts). A missing per-entry stamp (a migrated
|
|
887
987
|
// pre-#1186 record) inherits the record's `touchedAt`. Tolerance matches
|
|
888
988
|
// the Windows host-clock skew rationale above.
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
989
|
+
const mtimeStale = (d) => freshnessFromMtime({
|
|
990
|
+
mtimeMs,
|
|
991
|
+
referenceMs: d.observedAt ?? rec.touchedAt,
|
|
992
|
+
}).verdict === "stale";
|
|
993
|
+
// #3183: a disposition-tagged row carrying a strict anchor span is gated
|
|
994
|
+
// on its MARK, not on the file's mtime (retirement rule 1 — see
|
|
995
|
+
// `WidgetDiagnostic.suppressedRetained`): it survives while the marked
|
|
996
|
+
// LINE is still somewhere in the file, and is retired when it is not.
|
|
997
|
+
// The content read is gated on a span-carrying row the mtime gate WOULD
|
|
998
|
+
// have dropped — the only state where reading can change an outcome — so
|
|
999
|
+
// the debounced render sweep stays off the disk both for a file with no
|
|
1000
|
+
// marks and for a marked file nothing has touched.
|
|
1001
|
+
const spans = rec.allDiagnostics.some((d) => d.anchorSpan !== undefined && mtimeStale(d))
|
|
1002
|
+
? readAnchorSpans(rec.filePath)
|
|
1003
|
+
: undefined;
|
|
1004
|
+
const survivors = rec.allDiagnostics.filter((d) => d.anchorSpan !== undefined && spans !== undefined
|
|
1005
|
+
? spans.has(d.anchorSpan)
|
|
1006
|
+
: !mtimeStale(d));
|
|
896
1007
|
if (survivors.length === rec.allDiagnostics.length) {
|
|
897
1008
|
return { mapKey, action: "keep" }; // nothing stale
|
|
898
1009
|
}
|
|
@@ -1185,18 +1296,38 @@ export function scheduleStaleReconcile() {
|
|
|
1185
1296
|
* Used by lens_diagnostics tool (mode: "all"). Exposes the FULL per-file
|
|
1186
1297
|
* diagnostic set — decoupled from the widget's display cap — so the agent sees
|
|
1187
1298
|
* everything, not just the 12 the TUI keeps for rendering.
|
|
1299
|
+
*
|
|
1300
|
+
* #3183: "everything" means every row a live scan stands behind.
|
|
1301
|
+
* `disposition`-tagged rows are the store's own bookkeeping for a mark — the
|
|
1302
|
+
* scan that produced this record did not report them — and they are excluded
|
|
1303
|
+
* here. That is the same predicate `countDiagnostics` and `isBlocking` have
|
|
1304
|
+
* always used, so this makes the projected array agree with the counts beside
|
|
1305
|
+
* it; before #3183 the array disagreed, and once retirement rule 1 let a row
|
|
1306
|
+
* outlive the file's mtime, `applyCachedDispositions` reached it in the
|
|
1307
|
+
* WEAK-fallback branch — which can never match a STRICT `false-positive`
|
|
1308
|
+
* anchor — and `mode=all` served a marked finding as a live warning (#3158 AC3).
|
|
1309
|
+
* The exclusion lives HERE, at the one projection every cache-only surface and
|
|
1310
|
+
* the mode=full merge read, rather than as a second disposition rule inside
|
|
1311
|
+
* `summarizeDiagnostics`' tally: that tally still skips `stale` only, and the
|
|
1312
|
+
* disposition filter still reaches it pre-applied through the shared
|
|
1313
|
+
* `applyCachedDispositions` seam.
|
|
1314
|
+
*
|
|
1315
|
+
* Deliberately NOT applied to {@link getFileDiagnostics}, the per-file read:
|
|
1316
|
+
* `reconcileWidgetDisposition` needs the tagged rows to enforce retirement
|
|
1317
|
+
* rule 3, and `lens_diagnostic_mark`'s cross-check re-marks through them.
|
|
1188
1318
|
*/
|
|
1189
1319
|
export function getFileDiagnosticSummaries() {
|
|
1190
1320
|
return [...files.values()].map((rec) => {
|
|
1191
1321
|
applyPastEofGate(rec);
|
|
1322
|
+
const live = rec.allDiagnostics.filter((d) => d.disposition === undefined);
|
|
1192
1323
|
return {
|
|
1193
1324
|
filePath: rec.filePath,
|
|
1194
1325
|
blocking: rec.diagnosticCounts.blocking,
|
|
1195
1326
|
errors: rec.diagnosticCounts.errors,
|
|
1196
1327
|
warnings: rec.diagnosticCounts.warnings,
|
|
1197
|
-
advisories: countAdvisories(
|
|
1328
|
+
advisories: countAdvisories(live),
|
|
1198
1329
|
hasFinalSnapshot: rec.hasFinalDiagnosticsSnapshot,
|
|
1199
|
-
diagnostics:
|
|
1330
|
+
diagnostics: live.map((d) => ({ ...d })),
|
|
1200
1331
|
};
|
|
1201
1332
|
});
|
|
1202
1333
|
}
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import * as fs from "node:fs";
|
|
40
40
|
import * as os from "node:os";
|
|
41
41
|
import * as path from "node:path";
|
|
42
|
-
import { PROJECT_CONFIG_BASENAMES } from "./config-locations.js";
|
|
42
|
+
import { PROJECT_CONFIG_BASENAMES, isResolvedGlobalConfigPath, } from "./config-locations.js";
|
|
43
43
|
import { logLatency } from "./latency-logger.js";
|
|
44
44
|
import { isAtOrAboveHomeDir, nameMatchesMarkerGlob, walkUpDirs, } from "./path-utils.js";
|
|
45
45
|
/** Depth cap on any upward marker walk — mirrors `findNearestMarkerRoot`'s bound. */
|
|
@@ -214,9 +214,53 @@ export function getDirectoryMarkers(dir) {
|
|
|
214
214
|
function walkCacheKey(startDir, markerKey) {
|
|
215
215
|
return `${path.resolve(startDir)}\0${markerKey}`;
|
|
216
216
|
}
|
|
217
|
-
function
|
|
217
|
+
export function dirMtimesStillFresh(dirMtimes) {
|
|
218
218
|
return dirMtimes.every(({ dir, mtimeMs }) => safeDirMtimeMs(dir) === mtimeMs);
|
|
219
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Async arm of `dirMtimesStillFresh` — same records, same comparison, stats
|
|
222
|
+
* issued in parallel. It exists for the async marker walk in
|
|
223
|
+
* `clients/lsp/server.ts`'s `NearestRoot`, whose own marker probes are
|
|
224
|
+
* `fs/promises` and which runs on the per-file LSP touch path: a `statSync` per
|
|
225
|
+
* directory there would block the event loop the walk deliberately keeps free.
|
|
226
|
+
* Both arms live here so "is this recorded walk still current" has one owner
|
|
227
|
+
* (#3412).
|
|
228
|
+
*
|
|
229
|
+
* Written with promise combinators rather than `await`, along with the two
|
|
230
|
+
* readers below, so the ONLY place that waits on these stats is the caller's
|
|
231
|
+
* `await bounded(...)` — a hook-path await that a later editor cannot leave
|
|
232
|
+
* unbounded, instead of an inner await bounded only by a registry admission
|
|
233
|
+
* (#3412 review round 1, M-3421-02).
|
|
234
|
+
*/
|
|
235
|
+
export function dirMtimesStillFreshAsync(dirMtimes) {
|
|
236
|
+
return Promise.all(dirMtimes.map(({ dir }) => dirMtimeMsAsync(dir))).then((current) => dirMtimes.every(({ mtimeMs }, index) => current[index] === mtimeMs));
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Current mtime of `dir`, or `-1` when it cannot be stat'ed — the async arm of
|
|
240
|
+
* `safeDirMtimeMs`. A recorder and `dirMtimesStillFreshAsync` MUST read through
|
|
241
|
+
* the same function so the absent-directory sentinel is the same value on both
|
|
242
|
+
* sides: `prisma/` not existing yet is a recordable state, and the record must
|
|
243
|
+
* compare unequal once it is created (#3412).
|
|
244
|
+
*/
|
|
245
|
+
function dirMtimeMsAsync(dir) {
|
|
246
|
+
return fs.promises.stat(dir).then((stats) => stats.mtimeMs, () => -1);
|
|
247
|
+
}
|
|
248
|
+
/** One freshness record per directory, read in parallel (#3412). */
|
|
249
|
+
export function dirMtimeRecordsAsync(dirs) {
|
|
250
|
+
return Promise.all(dirs.map((dir) => dirMtimeMsAsync(dir).then((mtimeMs) => ({ dir, mtimeMs }))));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Records for directories whose mtime could NOT be read — because the caller's
|
|
254
|
+
* bound fired, so the answer is unknown rather than absent. `NaN` compares
|
|
255
|
+
* unequal to every mtime including itself, so an entry carrying one of these
|
|
256
|
+
* can never be served: the walk's answer is still returned, it is simply not
|
|
257
|
+
* memoized. Unknown must never be spelled as a number a directory could
|
|
258
|
+
* actually have, and dropping the directory from the signature (recording
|
|
259
|
+
* nothing) would hide every later change in it (#3412 review round 1, S9).
|
|
260
|
+
*/
|
|
261
|
+
export function unknownDirMtimeRecords(dirs) {
|
|
262
|
+
return dirs.map((dir) => ({ dir, mtimeMs: Number.NaN }));
|
|
263
|
+
}
|
|
220
264
|
/**
|
|
221
265
|
* Shared core of the two nearest-marker walkers below: the cache check, the
|
|
222
266
|
* home-guarded/depth-capped upward loop, per-directory mtime recording, and
|
|
@@ -229,7 +273,7 @@ function walkStillFresh(dirMtimes) {
|
|
|
229
273
|
function walkToNearestMatch(startDir, cacheSuffix, matches, capMetadata, homeDir) {
|
|
230
274
|
const key = walkCacheKey(startDir, cacheSuffix);
|
|
231
275
|
const cached = walkCache.get(key);
|
|
232
|
-
if (cached &&
|
|
276
|
+
if (cached && dirMtimesStillFresh(cached.dirMtimes)) {
|
|
233
277
|
touchWalk(key, cached);
|
|
234
278
|
return cached.dir;
|
|
235
279
|
}
|
|
@@ -339,11 +383,21 @@ export function findNearestDirWithAnyBasename(startDir, basenames, homeDir = os.
|
|
|
339
383
|
* `.pi-lens.json`/`pi-lens.json` directly IN `dir` — no upward walk. The
|
|
340
384
|
* shared-index equivalent of `project-lens-config.ts`'s old private
|
|
341
385
|
* `findPiLensConfigInDir` probe loop.
|
|
386
|
+
*
|
|
387
|
+
* A candidate that IS the resolved global config path is refused
|
|
388
|
+
* (global-config-location PR, refs #2457): the global tier reads that file by
|
|
389
|
+
* absolute path, and discovering it here a second time made the nested ignore
|
|
390
|
+
* layering full-validate it and warn that its global-only keys are "not
|
|
391
|
+
* honored in a project config" — including the notice advising the user to
|
|
392
|
+
* set them in the very file being ignored. The refusal is observable through
|
|
393
|
+
* the marker staying absent, the same shape as a directory with no marker.
|
|
342
394
|
*/
|
|
343
395
|
export function findPiLensConfigMarkerInDir(dir) {
|
|
344
396
|
const markers = getDirectoryMarkers(dir);
|
|
345
397
|
if (!markers.piLensConfigPath)
|
|
346
398
|
return undefined;
|
|
399
|
+
if (isResolvedGlobalConfigPath(markers.piLensConfigPath))
|
|
400
|
+
return undefined;
|
|
347
401
|
const stat = (() => {
|
|
348
402
|
try {
|
|
349
403
|
return fs.statSync(markers.piLensConfigPath);
|
|
@@ -157,10 +157,12 @@ function classifyGhTokenFailure(res, hostStallMs) {
|
|
|
157
157
|
classifiedBy: "caller",
|
|
158
158
|
};
|
|
159
159
|
}
|
|
160
|
-
async function deriveGhCliToken() {
|
|
160
|
+
async function deriveGhCliToken(consumer) {
|
|
161
161
|
const sampler = startHostStallSampler();
|
|
162
162
|
const startedAt = Date.now();
|
|
163
|
-
// Best-effort: a missing/unauthenticated `gh`
|
|
163
|
+
// Best-effort: a missing/unauthenticated `gh` leaves the requesting
|
|
164
|
+
// consumer without a derived token; only the zizmor wrapper turns that into
|
|
165
|
+
// an offline-mode degradation.
|
|
164
166
|
// ignoreAmbientSignal so a mid-turn Esc can't silently drop the server into
|
|
165
167
|
// offline mode; short timeout so a wedged `gh` never stalls the warm spawn.
|
|
166
168
|
// safeSpawnAsync never rejects (every failure resolves into `res`), so no
|
|
@@ -176,7 +178,7 @@ async function deriveGhCliToken() {
|
|
|
176
178
|
if (token.length > 0) {
|
|
177
179
|
ghTokenLatch.noteAvailable();
|
|
178
180
|
logAvailabilityDecision({
|
|
179
|
-
tool: "zizmor-gh-token",
|
|
181
|
+
tool: consumer === "zizmor" ? "zizmor-gh-token" : "github-token",
|
|
180
182
|
verdict: "available",
|
|
181
183
|
outcome: "success",
|
|
182
184
|
cause: "ok",
|
|
@@ -195,13 +197,13 @@ async function deriveGhCliToken() {
|
|
|
195
197
|
// regardless of whether `token` was empty, so the record claimed the
|
|
196
198
|
// online audits ran while zizmor was actually about to launch offline
|
|
197
199
|
// — the #1535 silence moved into the telemetry instead of being fixed.
|
|
198
|
-
return recordGhTokenUnavailable({
|
|
200
|
+
return recordGhTokenUnavailable(consumer, {
|
|
199
201
|
outcome: "non-installable",
|
|
200
202
|
cause: "empty-result",
|
|
201
203
|
classifiedBy: "caller",
|
|
202
204
|
}, elapsedMs, hostStallMs);
|
|
203
205
|
}
|
|
204
|
-
return recordGhTokenUnavailable(classifyGhTokenFailure(res, hostStallMs), elapsedMs, hostStallMs);
|
|
206
|
+
return recordGhTokenUnavailable(consumer, classifyGhTokenFailure(res, hostStallMs), elapsedMs, hostStallMs);
|
|
205
207
|
}
|
|
206
208
|
/**
|
|
207
209
|
* Shared tail for every "no token" verdict: latches (or cools down) the
|
|
@@ -209,13 +211,13 @@ async function deriveGhCliToken() {
|
|
|
209
211
|
* and logs the decision. Centralized so the empty-answer and failure paths
|
|
210
212
|
* can't drift on which fields get set.
|
|
211
213
|
*/
|
|
212
|
-
function recordGhTokenUnavailable({ outcome, cause, classifiedBy }, elapsedMs, hostStallMs) {
|
|
214
|
+
function recordGhTokenUnavailable(consumer, { outcome, cause, classifiedBy }, elapsedMs, hostStallMs) {
|
|
213
215
|
const retryAfterMs = ghTokenLatch.noteUnavailable(outcome, cause);
|
|
214
|
-
if (outcome === "transient") {
|
|
216
|
+
if (consumer === "zizmor" && outcome === "transient") {
|
|
215
217
|
recordZizmorOfflineDegradation(`gh auth token probe ${cause}; running offline until the next zizmor start (retry allowed in ${Math.round(retryAfterMs / 1000)}s)`);
|
|
216
218
|
}
|
|
217
219
|
logAvailabilityDecision({
|
|
218
|
-
tool: "zizmor-gh-token",
|
|
220
|
+
tool: consumer === "zizmor" ? "zizmor-gh-token" : "github-token",
|
|
219
221
|
verdict: "unavailable",
|
|
220
222
|
outcome,
|
|
221
223
|
cause,
|
|
@@ -278,14 +280,25 @@ export async function resolveZizmorGitHubToken() {
|
|
|
278
280
|
// Respect an explicit offline request — never derive a token then.
|
|
279
281
|
if (process.env.ZIZMOR_OFFLINE)
|
|
280
282
|
return undefined;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
283
|
+
return resolveGitHubToken(["ZIZMOR_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], { consumer: "zizmor" });
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Resolve a GitHub token for an API client. Explicit environment variables are
|
|
287
|
+
* checked in the caller's order; when none is present, reuse the bounded,
|
|
288
|
+
* session-reset `gh auth token` resolver above rather than spawning once per
|
|
289
|
+
* request. The returned token is ephemeral and callers must not persist or
|
|
290
|
+
* send it to non-API hosts.
|
|
291
|
+
*/
|
|
292
|
+
export async function resolveGitHubToken(envKeys = ["GITHUB_TOKEN", "GH_TOKEN"], options = {}) {
|
|
293
|
+
const consumer = options.consumer ?? "github-api";
|
|
294
|
+
const fromEnv = envKeys.map((key) => process.env[key]).find(Boolean);
|
|
284
295
|
if (fromEnv)
|
|
285
296
|
return fromEnv;
|
|
286
297
|
const memo = ghTokenLatch.read();
|
|
287
298
|
if (memo !== null) {
|
|
288
|
-
if (
|
|
299
|
+
if (consumer === "zizmor" &&
|
|
300
|
+
memo === false &&
|
|
301
|
+
ghTokenLatch.getOutcome() === "transient") {
|
|
289
302
|
// Served straight from the still-cooling latch: no new probe runs,
|
|
290
303
|
// so `deriveGhCliToken`'s own logging never fires. Without this, once
|
|
291
304
|
// the cooldown ladder crosses zizmor's own respawn cadence (bounded
|
|
@@ -322,6 +335,6 @@ export async function resolveZizmorGitHubToken() {
|
|
|
322
335
|
}
|
|
323
336
|
return memo ? cachedToken : undefined;
|
|
324
337
|
}
|
|
325
|
-
cachedToken = await deriveGhCliToken();
|
|
338
|
+
cachedToken = await deriveGhCliToken(consumer);
|
|
326
339
|
return cachedToken;
|
|
327
340
|
}
|