@herbertgao/pi-extensions 2026.9.15 → 2026.9.17
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 +5 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
- package/node_modules/@herbertgao/sol-pi/package.json +1 -1
- package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +11 -53
- 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-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-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 +9 -6
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disposition/rule/inline-suppression policy for the turn-end
|
|
3
|
+
* "Unresolved from this turn" blocker replay (#3246).
|
|
4
|
+
*
|
|
5
|
+
* `runtime-turn.ts` re-serves every live `InlineBlockerRecord` the turn's
|
|
6
|
+
* dispatches left behind. Until this module, it pushed the record's
|
|
7
|
+
* ALREADY-RENDERED `summary` string verbatim, so a `lens_diagnostic_mark`
|
|
8
|
+
* made after the record was written had nothing to act on: the durable
|
|
9
|
+
* disposition store was honored by `lens_diagnostics`, the widget, the cached
|
|
10
|
+
* scanner lanes, late auxiliary and cascade, while this one surface replayed
|
|
11
|
+
* the pre-mark text on every later turn — including turns that edited only an
|
|
12
|
+
* unrelated file. That is #3246's report.
|
|
13
|
+
*
|
|
14
|
+
* The fix is the same shape `blocker-past-eof.ts` already argued for the
|
|
15
|
+
* `lines` field: carry the STRUCTURED diagnostics the summary was rendered
|
|
16
|
+
* from (`dispatchResult.blockers`, via `PipelineResult.inlineBlockerDiagnostics`
|
|
17
|
+
* and `InlineBlockerRecord.diagnostics`) and re-derive at read time, rather
|
|
18
|
+
* than regex-parsing a string built for human display. With the diagnostics in
|
|
19
|
+
* hand this module runs the ONE filter stack every other findings surface runs
|
|
20
|
+
* — `clients/dispatch/finding-policy.ts`'s inline `pi-lens-ignore` → stored
|
|
21
|
+
* dispositions → `.pi-lens.json` rule policy — against the file's CURRENT
|
|
22
|
+
* bytes, and re-renders the survivors with `formatDiagnostics(..., "blocking")`,
|
|
23
|
+
* the SAME renderer `dispatcher.ts` used to build the stored summary. No second
|
|
24
|
+
* anchor implementation and no second renderer: an unmarked record therefore
|
|
25
|
+
* re-renders byte-identically to what dispatch produced.
|
|
26
|
+
*
|
|
27
|
+
* Ordering (#3246 mutation (c)): the policy runs over the WHOLE candidate set
|
|
28
|
+
* and `formatDiagnostics` applies its 10-row display cap to the survivors.
|
|
29
|
+
* Filtering after the cap would leave a marked finding occupying a display slot
|
|
30
|
+
* and hide an unmarked one behind "... and N more" — the cap-before-policy
|
|
31
|
+
* shape `cascade-format.ts` already avoids.
|
|
32
|
+
*/
|
|
33
|
+
import * as fs from "node:fs";
|
|
34
|
+
import * as path from "node:path";
|
|
35
|
+
import { recordDegradationOnce } from "./degradation-ledger.js";
|
|
36
|
+
import { hasAnyDispositionMarks } from "./diagnostic-dispositions.js";
|
|
37
|
+
import { applyFindingPolicy, inlineBlockerIdentities, loadProjectRulePolicyMap, } from "./dispatch/finding-policy.js";
|
|
38
|
+
import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
|
|
39
|
+
import { formatDiagnostics } from "./dispatch/utils/format-utils.js";
|
|
40
|
+
/**
|
|
41
|
+
* The file's CURRENT bytes, or `""` when they cannot be read.
|
|
42
|
+
*
|
|
43
|
+
* `""` is `FindingPolicyOptions.content`'s documented fail-open input: inline
|
|
44
|
+
* suppression becomes a no-op and the STRICT anchor hashes an empty line, so it
|
|
45
|
+
* cannot match a mark made against real content and the finding stays VISIBLE.
|
|
46
|
+
* Never hide a blocking finding over an I/O error (AGENTS.md shape 48).
|
|
47
|
+
*/
|
|
48
|
+
function readCurrentContent(filePath) {
|
|
49
|
+
try {
|
|
50
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Apply the shared finding policy to one live inline-blocker record.
|
|
58
|
+
*
|
|
59
|
+
* Diagnostics are grouped by the file they were raised AGAINST, not by the
|
|
60
|
+
* record's own path: `dispatchResult.blockers` is pooled across every runner
|
|
61
|
+
* dispatched for the edited file, and a chart-wide runner (helm-lint,
|
|
62
|
+
* helm-render) reports blocking diagnostics against sibling files — the same
|
|
63
|
+
* cross-file population `pipeline.ts` filters out of `inlineBlockerLines`.
|
|
64
|
+
* Anchoring those against the record's path would derive the wrong strict
|
|
65
|
+
* anchor and read the wrong file's `pi-lens-ignore` comments.
|
|
66
|
+
*/
|
|
67
|
+
export function applyInlineBlockerPolicy(record, cwd) {
|
|
68
|
+
const diagnostics = record.diagnostics;
|
|
69
|
+
if (!diagnostics || diagnostics.length === 0) {
|
|
70
|
+
// Bounded per (kind, subject) by the ledger itself — this condition
|
|
71
|
+
// recurs every turn end for the same record, so a counted row would
|
|
72
|
+
// grow without adding information (AGENTS.md shapes 13/17). Recorded
|
|
73
|
+
// ONLY when the project actually holds marks: with an empty store there
|
|
74
|
+
// is nothing this record failed to honor, and a row every session for
|
|
75
|
+
// every unstructured record would be noise, not a degradation.
|
|
76
|
+
if (hasAnyDispositionMarks(cwd)) {
|
|
77
|
+
recordDegradationOnce({
|
|
78
|
+
kind: "inline-blocker-unstructured",
|
|
79
|
+
subject: `inline-blocker:${toRunnerDisplayPath(cwd, record.filePath)}`,
|
|
80
|
+
reason: "record carries no structured diagnostics; disposition marks cannot be applied to its rendered summary",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
body: record.summary,
|
|
85
|
+
candidates: 0,
|
|
86
|
+
kept: 0,
|
|
87
|
+
suppressed: 0,
|
|
88
|
+
unstructured: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const byFile = new Map();
|
|
92
|
+
for (const diagnostic of diagnostics) {
|
|
93
|
+
// Resolved against the PROJECT root, never `process.cwd()`: a runner that
|
|
94
|
+
// reports a project-relative path would otherwise be grouped under the
|
|
95
|
+
// agent host's working directory and read the wrong file's bytes. No
|
|
96
|
+
// case/separator fold on the key (catalog shape 1): two spellings of one
|
|
97
|
+
// file would only cost a duplicate read here — `anchorsForDiagnostic`
|
|
98
|
+
// canonicalizes both the cwd and the file path itself, so the anchors,
|
|
99
|
+
// and therefore the verdict, are identical either way.
|
|
100
|
+
const owner = path.resolve(cwd, diagnostic.filePath);
|
|
101
|
+
const group = byFile.get(owner);
|
|
102
|
+
if (group)
|
|
103
|
+
group.push(diagnostic);
|
|
104
|
+
else
|
|
105
|
+
byFile.set(owner, [diagnostic]);
|
|
106
|
+
}
|
|
107
|
+
// `loadPiLensProjectConfig` is mtime-cached, so several groups cost one stat.
|
|
108
|
+
const policyMap = loadProjectRulePolicyMap(cwd);
|
|
109
|
+
const dropped = new Set();
|
|
110
|
+
for (const [filePath, group] of byFile) {
|
|
111
|
+
const { kept } = applyFindingPolicy(group, {
|
|
112
|
+
cwd,
|
|
113
|
+
filePath,
|
|
114
|
+
content: readCurrentContent(filePath),
|
|
115
|
+
policyMap,
|
|
116
|
+
identities: inlineBlockerIdentities,
|
|
117
|
+
});
|
|
118
|
+
const survivors = new Set(kept);
|
|
119
|
+
for (const diagnostic of group) {
|
|
120
|
+
if (!survivors.has(diagnostic))
|
|
121
|
+
dropped.add(diagnostic);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const kept = dropped.size === 0
|
|
125
|
+
? [...diagnostics]
|
|
126
|
+
: diagnostics.filter((diagnostic) => !dropped.has(diagnostic));
|
|
127
|
+
return {
|
|
128
|
+
body: kept.length === 0
|
|
129
|
+
? undefined
|
|
130
|
+
: formatDiagnostics(kept, "blocking").trim(),
|
|
131
|
+
candidates: diagnostics.length,
|
|
132
|
+
kept: kept.length,
|
|
133
|
+
suppressed: dropped.size,
|
|
134
|
+
unstructured: false,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* How many file/tool identities {@link summarizeInlineBlockerPolicy}'s one
|
|
139
|
+
* per-turn record names. The COUNTS are always exact; only the identity lists
|
|
140
|
+
* are capped, so a turn that touched a hundred files writes a bounded row
|
|
141
|
+
* rather than a file listing (AGENTS.md shape 17).
|
|
142
|
+
*/
|
|
143
|
+
const IDENTITY_CAP = 10;
|
|
144
|
+
/**
|
|
145
|
+
* The ONE bounded record the turn writes for this pass — never one per
|
|
146
|
+
* finding. Derived here rather than accumulated at the call site so the shape
|
|
147
|
+
* and its bound live with the policy they describe.
|
|
148
|
+
*/
|
|
149
|
+
export function summarizeInlineBlockerPolicy(entries) {
|
|
150
|
+
const files = [];
|
|
151
|
+
const tools = new Set();
|
|
152
|
+
let stale = 0;
|
|
153
|
+
let candidates = 0;
|
|
154
|
+
let kept = 0;
|
|
155
|
+
let suppressed = 0;
|
|
156
|
+
let unstructured = 0;
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
for (const tool of entry.sources ?? [])
|
|
159
|
+
tools.add(tool);
|
|
160
|
+
if (entry.stale)
|
|
161
|
+
stale += 1;
|
|
162
|
+
const outcome = entry.outcome;
|
|
163
|
+
if (outcome === undefined)
|
|
164
|
+
continue;
|
|
165
|
+
candidates += outcome.candidates;
|
|
166
|
+
kept += outcome.kept;
|
|
167
|
+
suppressed += outcome.suppressed;
|
|
168
|
+
if (outcome.unstructured)
|
|
169
|
+
unstructured += 1;
|
|
170
|
+
// The files a mark actually acted on — the identity a reader needs to
|
|
171
|
+
// reconstruct a replay report.
|
|
172
|
+
if (outcome.suppressed > 0 && files.length < IDENTITY_CAP) {
|
|
173
|
+
files.push(entry.displayPath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
records: entries.length,
|
|
178
|
+
stale,
|
|
179
|
+
candidates,
|
|
180
|
+
kept,
|
|
181
|
+
dispositionSuppressed: suppressed,
|
|
182
|
+
unstructured,
|
|
183
|
+
files,
|
|
184
|
+
tools: [...tools].slice(0, IDENTITY_CAP),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -13,6 +13,10 @@ import { createRequire } from "node:module";
|
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
14
|
import * as path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
+
// The shared web-tree-sitter ladder imports nothing but node:fs/node:path, so
|
|
17
|
+
// importing it keeps this module's "loads even when the deps that failed are
|
|
18
|
+
// unreachable" property intact.
|
|
19
|
+
import { resolveWebTreeSitterPackageDir } from "../scripts/lib/web-tree-sitter-dir.mjs";
|
|
16
20
|
const require = createRequire(import.meta.url);
|
|
17
21
|
const ISSUES_URL = "https://github.com/apmantza/pi-lens/issues";
|
|
18
22
|
/** Runtime third-party deps whose resolution is the documented failure surface. */
|
|
@@ -88,12 +92,21 @@ export function collectInstallDiagnostics() {
|
|
|
88
92
|
return ["ast-grep", "ast-grep.exe", "sg", "sg.exe"].some((b) => fs.existsSync(path.join(dir, b)));
|
|
89
93
|
}, false);
|
|
90
94
|
const grammars = safe(() => {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
// #3409 (sweep sibling of the same shape): this probe used to walk up from
|
|
96
|
+
// the BARE `web-tree-sitter` specifier, so on the host pi ships — a `bun
|
|
97
|
+
// build --compile` binary, where a bare specifier throws MODULE_NOT_FOUND
|
|
98
|
+
// — it reported `grammars: false` for an install whose grammars were
|
|
99
|
+
// present. The one report a user pastes when the long tail of grammars is
|
|
100
|
+
// dead has to measure the same directory the client does, through the same
|
|
101
|
+
// ladder.
|
|
102
|
+
const dir = resolveWebTreeSitterPackageDir({
|
|
103
|
+
resolve: (specifier) => require.resolve(specifier),
|
|
104
|
+
// `root` is this probe's own walk to the package root, computed above.
|
|
105
|
+
packageRoot: () => root,
|
|
106
|
+
cwd: () => process.cwd(),
|
|
107
|
+
});
|
|
108
|
+
return (!!dir &&
|
|
109
|
+
fs.existsSync(path.join(dir, "grammars", "tree-sitter-typescript.wasm")));
|
|
97
110
|
}, false);
|
|
98
111
|
if (deps.some((d) => !d.resolved)) {
|
|
99
112
|
notes.push("One or more runtime dependencies did not resolve. This is the #285/#335 failure mode — usually a package-manager layout (pnpm symlink store / nested install) the runtime's resolver can't traverse, or an outdated runtime.");
|
|
@@ -68,8 +68,10 @@ import { createGenerationMap } from "../generation-guard.js";
|
|
|
68
68
|
import { resolveToolCwd } from "../tool-cwd.js";
|
|
69
69
|
import { allAvailableGlobalBinDirs, installArgs, pmBinary, resolveNodePackageManager, } from "../package-manager.js";
|
|
70
70
|
import { resetSafeSpawnWindowsCommandCache, safeSpawnAsync, } from "../safe-spawn.js";
|
|
71
|
+
import { createBoundedOutputSink, DEFAULT_MAX_OUTPUT_BYTES, } from "../spawn-output-cap.js";
|
|
71
72
|
import { probeToolAsync } from "../tool-probe.js";
|
|
72
73
|
import { logSessionStart } from "../sessionstart-logger.js";
|
|
74
|
+
import { resolveGitHubToken } from "../zizmor-config.js";
|
|
73
75
|
// Global installation directory for pi-lens tools
|
|
74
76
|
const TOOLS_DIR = path.join(getGlobalPiLensDir(), "tools");
|
|
75
77
|
const INSTALL_LOCK_PATH = path.join(TOOLS_DIR, ".install.lock");
|
|
@@ -247,7 +249,15 @@ const MANAGED_PACKAGE_FORMATTERS = [
|
|
|
247
249
|
id: "cmake-format",
|
|
248
250
|
name: "cmake-format",
|
|
249
251
|
installStrategy: "pip",
|
|
250
|
-
|
|
252
|
+
// The `yaml` EXTRA, never the bare distribution (#3312). cmakelang reads a
|
|
253
|
+
// `.cmake-format.yaml` project config through `import yaml`, and upstream
|
|
254
|
+
// puts PyYAML behind an extra: cmakelang 0.6.13's PyPI metadata declares
|
|
255
|
+
// `pyyaml (>=5.3) ; extra == 'yaml'`. A bare install still answers
|
|
256
|
+
// `cmake-format --version` with status 0 and then dies with
|
|
257
|
+
// `ModuleNotFoundError: No module named 'yaml'` on the first YAML config —
|
|
258
|
+
// the nightly's red cmake row. Every rung of the pip ladder passes this
|
|
259
|
+
// string verbatim to pip/pipx, which both accept the `pkg[extra]` spec.
|
|
260
|
+
packageName: "cmakelang[yaml]",
|
|
251
261
|
},
|
|
252
262
|
{ id: "oxfmt", name: "oxfmt", installStrategy: "npm", packageName: "oxfmt" },
|
|
253
263
|
];
|
|
@@ -465,7 +475,7 @@ export const TOOLS = [
|
|
|
465
475
|
checkCommand: "jscpd",
|
|
466
476
|
checkArgs: ["--version"],
|
|
467
477
|
installStrategy: "npm",
|
|
468
|
-
packageName: "jscpd@5.0
|
|
478
|
+
packageName: "jscpd@5.3.0", // v5.3.0 is the repository's exact devDependency; the v4 packaging defect that required the older v5.0.12 pin is gone, and parseReport() reads the unchanged clone-report fields.
|
|
469
479
|
binaryName: "jscpd",
|
|
470
480
|
},
|
|
471
481
|
// Structural search and dead code detection
|
|
@@ -549,6 +559,15 @@ export const TOOLS = [
|
|
|
549
559
|
installStrategy: "pip",
|
|
550
560
|
packageName: "cmake-language-server",
|
|
551
561
|
binaryName: "cmake-language-server",
|
|
562
|
+
// Upstream 0.1.11 (the latest release) imports `LanguageServer` from
|
|
563
|
+
// `pygls.server`, a symbol pygls 2 removed, while declaring only
|
|
564
|
+
// `pygls>=1.1.1` — so an unconstrained install resolves pygls 2.1.1 and
|
|
565
|
+
// produces a launcher that cannot start. Measured against PyPI for the
|
|
566
|
+
// nightly's interpreter (#3311): `pip download --python-version 3.12
|
|
567
|
+
// cmake-language-server` → `pygls-2.1.1`; with this constraint →
|
|
568
|
+
// `pygls-1.3.1` + `lsprotocol-2023.0.1`, and `cmake-language-server
|
|
569
|
+
// --version` then prints `cmake-language-server 0.1.11` and exits 0.
|
|
570
|
+
pipConstraints: ["pygls<2"],
|
|
552
571
|
},
|
|
553
572
|
{
|
|
554
573
|
id: "yaml-language-server",
|
|
@@ -2585,9 +2604,58 @@ async function getToolPathResolved(toolId, onTransient) {
|
|
|
2585
2604
|
if (githubPath)
|
|
2586
2605
|
return githubPath;
|
|
2587
2606
|
}
|
|
2588
|
-
// Check if global
|
|
2589
|
-
|
|
2590
|
-
|
|
2607
|
+
// Check if global. `isCommandAvailable` is a PATH walk plus a stat — it
|
|
2608
|
+
// ignores its `_args` parameter by construction — so it answers "a file with
|
|
2609
|
+
// that name is on PATH", never "that command runs". Every OTHER rung of this
|
|
2610
|
+
// ladder spawn-verifies its candidate with the entry's own `checkArgs` before
|
|
2611
|
+
// resolving to it; this rung did not, and a PATH entry that is a file but
|
|
2612
|
+
// cannot run then SHADOWED the managed install that would have worked
|
|
2613
|
+
// (#3311 lane C):
|
|
2614
|
+
// - rust-analyzer: rustup's `DUP_TOOLS` proxy (rustup 1.29.1 src/lib.rs:32)
|
|
2615
|
+
// sits in ~/.cargo/bin on every rustup box whether or not the
|
|
2616
|
+
// `rust-analyzer` COMPONENT is installed. Where it is not, the proxy
|
|
2617
|
+
// errors out instead of speaking LSP — and the github-release install
|
|
2618
|
+
// pi-lens would have downloaded was never attempted.
|
|
2619
|
+
// - cmake-language-server: `pipx install` exits 0 and drops a launcher on
|
|
2620
|
+
// PATH whose venv resolved pygls 2.x, which removed the symbol the 0.1.11
|
|
2621
|
+
// server imports. This rung returned it ahead of the pip-user rung, whose
|
|
2622
|
+
// verification would have caught it.
|
|
2623
|
+
// A VERDICT — the binary ran and rejected its own check (nonzero exit) —
|
|
2624
|
+
// falls through to the rungs below and, for an installable strategy, to the
|
|
2625
|
+
// managed install. A STALL (timeout/signal, or a spawn-boundary refusal the
|
|
2626
|
+
// binary never saw) and an INCONCLUSIVE probe are not verdicts (#1569/#2722
|
|
2627
|
+
// semantics), so those keep the pre-#3311 behaviour and resolve to PATH —
|
|
2628
|
+
// dropping a working-but-slow tool on a kill would be a worse lie than the
|
|
2629
|
+
// one this fixes. `recordVersion` is deliberately NOT passed: version-pin
|
|
2630
|
+
// drift (#589) is about pi-lens's own managed installs, and feeding a
|
|
2631
|
+
// system-installed version into it would turn every PATH tool at another
|
|
2632
|
+
// version into a forced reinstall.
|
|
2633
|
+
if (await isCommandAvailable(tool.checkCommand)) {
|
|
2634
|
+
// A `verification: "package-entry"` entry (#2722) is verified from the
|
|
2635
|
+
// installed tree BESIDE its shim — `verifyNpmPackageEntry` derives the
|
|
2636
|
+
// package dir from `<…>/node_modules/.bin/<shim>`. A bare PATH name has no
|
|
2637
|
+
// such tree to read, so that evidence is unavailable here and its absence
|
|
2638
|
+
// says nothing about the command: this rung keeps the pre-#3311 behaviour
|
|
2639
|
+
// for those entries rather than inventing a verdict from a failed lookup.
|
|
2640
|
+
if (packageEntryVerification(tool) !== undefined)
|
|
2641
|
+
return tool.checkCommand;
|
|
2642
|
+
let probeStalled = false;
|
|
2643
|
+
const verified = await verifyToolBinary(tool.checkCommand, undefined, () => {
|
|
2644
|
+
probeStalled = true;
|
|
2645
|
+
onTransient();
|
|
2646
|
+
}, getToolVerificationTimeout(tool), tool.checkArgs, undefined, () => {
|
|
2647
|
+
probeStalled = true;
|
|
2648
|
+
});
|
|
2649
|
+
if (verified || probeStalled)
|
|
2650
|
+
return tool.checkCommand;
|
|
2651
|
+
// One record per tool per session: the rejected candidate and the check
|
|
2652
|
+
// that rejected it. verifyToolBinary already logged the kind/exit code.
|
|
2653
|
+
recordDegradationOnce({
|
|
2654
|
+
kind: "installer-path-candidate-unrunnable",
|
|
2655
|
+
subject: toolId,
|
|
2656
|
+
reason: `${tool.checkCommand} on PATH failed its own check (${tool.checkArgs.join(" ")}); ignoring PATH for this tool`,
|
|
2657
|
+
});
|
|
2658
|
+
logSessionStart(`auto-install ${toolId}: PATH candidate ${tool.checkCommand} failed ${tool.checkArgs.join(" ")} — ignoring PATH, trying managed install`);
|
|
2591
2659
|
}
|
|
2592
2660
|
if (tool.installStrategy === "npm") {
|
|
2593
2661
|
const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id, onTransient, tool.checkArgs, getToolVerificationTimeout(tool));
|
|
@@ -2775,6 +2843,39 @@ async function findPipUserToolPath(binaryName, onTransient, verificationArgs = [
|
|
|
2775
2843
|
}
|
|
2776
2844
|
return undefined;
|
|
2777
2845
|
}
|
|
2846
|
+
/**
|
|
2847
|
+
* The verdict of one `<interpreter> -m site --user-base` probe (#3383).
|
|
2848
|
+
*
|
|
2849
|
+
* Both probes accumulate through {@link createBoundedOutputSink} instead of
|
|
2850
|
+
* `stdout += data`: the concatenation ran inside a `data` handler, where V8's
|
|
2851
|
+
* `RangeError: Invalid string length` is an uncaught exception rather than this
|
|
2852
|
+
* promise's value, and nothing bounded an interpreter that decides to write
|
|
2853
|
+
* forever. A TRUNCATED probe resolves EMPTY rather than trimming a prefix,
|
|
2854
|
+
* because the prefix of a path is a different path: `addBinToPath` would put a
|
|
2855
|
+
* plausible-looking wrong directory on PATH. Empty is the value both probes
|
|
2856
|
+
* already resolve when an interpreter is missing, and every caller handles it.
|
|
2857
|
+
*
|
|
2858
|
+
* Exported for `tests/clients/off-seam-output-bounds.test.ts`: both `data`
|
|
2859
|
+
* handlers that feed it sit inside module-private probe loops that only a real
|
|
2860
|
+
* `python3` on PATH can drive, so this is the seam the bound is pinned at.
|
|
2861
|
+
*/
|
|
2862
|
+
export function userBaseProbeResult(command, code, stdout) {
|
|
2863
|
+
if (stdout.truncated) {
|
|
2864
|
+
recordDegradationOnce({
|
|
2865
|
+
kind: "spawn-output-cap-truncated",
|
|
2866
|
+
subject: `user-base-probe:${command}`,
|
|
2867
|
+
reason: `\`${command} -m site --user-base\` reached the ${DEFAULT_MAX_OUTPUT_BYTES}-byte default cap after ${stdout.observedBytes} bytes; the probed user base is unusable`,
|
|
2868
|
+
metadata: {
|
|
2869
|
+
capBytes: DEFAULT_MAX_OUTPUT_BYTES,
|
|
2870
|
+
capSource: "default",
|
|
2871
|
+
observedBytes: stdout.observedBytes,
|
|
2872
|
+
killed: false,
|
|
2873
|
+
},
|
|
2874
|
+
});
|
|
2875
|
+
return "";
|
|
2876
|
+
}
|
|
2877
|
+
return code === 0 ? stdout.text.trim() : "";
|
|
2878
|
+
}
|
|
2778
2879
|
async function getPythonUserBaseCandidates() {
|
|
2779
2880
|
const candidates = [];
|
|
2780
2881
|
const seen = new Set();
|
|
@@ -2821,9 +2922,9 @@ async function getPythonUserBaseCandidates() {
|
|
|
2821
2922
|
resolve("");
|
|
2822
2923
|
return;
|
|
2823
2924
|
}
|
|
2824
|
-
|
|
2825
|
-
proc.stdout?.on("data", (data) => (
|
|
2826
|
-
proc.on("exit", (code) => resolve(code
|
|
2925
|
+
const stdout = createBoundedOutputSink();
|
|
2926
|
+
proc.stdout?.on("data", (data) => stdout.append(data));
|
|
2927
|
+
proc.on("exit", (code) => resolve(userBaseProbeResult(probe.command, code, stdout)));
|
|
2827
2928
|
proc.on("error", () => resolve(""));
|
|
2828
2929
|
});
|
|
2829
2930
|
add(userBase);
|
|
@@ -2839,10 +2940,42 @@ async function getPythonUserBaseCandidates() {
|
|
|
2839
2940
|
* call, never the asset download (see installGitHubTool) — the release CDN must
|
|
2840
2941
|
* not receive the token.
|
|
2841
2942
|
*/
|
|
2842
|
-
function githubApiAuthHeaders() {
|
|
2843
|
-
const token =
|
|
2943
|
+
async function githubApiAuthHeaders() {
|
|
2944
|
+
const token = await resolveGitHubToken();
|
|
2844
2945
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
2845
2946
|
}
|
|
2947
|
+
class HttpStatusError extends Error {
|
|
2948
|
+
statusCode;
|
|
2949
|
+
constructor(statusCode, url) {
|
|
2950
|
+
super(url ? `HTTP ${statusCode} for ${url}` : `HTTP ${statusCode}`);
|
|
2951
|
+
this.name = "HttpStatusError";
|
|
2952
|
+
this.statusCode = statusCode;
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
class GitHubHttpError extends HttpStatusError {
|
|
2956
|
+
anonymousRateLimitExhausted;
|
|
2957
|
+
constructor(statusCode, requestHeaders, responseHeaders) {
|
|
2958
|
+
super(statusCode);
|
|
2959
|
+
this.name = "GitHubHttpError";
|
|
2960
|
+
this.message = `GitHub API HTTP ${statusCode}`;
|
|
2961
|
+
this.anonymousRateLimitExhausted =
|
|
2962
|
+
statusCode === 403 &&
|
|
2963
|
+
!Object.keys(requestHeaders).some((key) => key.toLowerCase() === "authorization") &&
|
|
2964
|
+
readHeader(responseHeaders, "x-ratelimit-remaining") === "0";
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
function readHeader(headers, name) {
|
|
2968
|
+
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
|
|
2969
|
+
return Array.isArray(entry) ? entry[0]?.trim() : entry?.trim();
|
|
2970
|
+
}
|
|
2971
|
+
function isGitHubApiUrl(url) {
|
|
2972
|
+
try {
|
|
2973
|
+
return new URL(url).host.toLowerCase() === "api.github.com";
|
|
2974
|
+
}
|
|
2975
|
+
catch {
|
|
2976
|
+
return false;
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2846
2979
|
function sameHost(a, b) {
|
|
2847
2980
|
try {
|
|
2848
2981
|
return new URL(a).host === new URL(b).host;
|
|
@@ -2891,7 +3024,9 @@ function httpsGetWithMeta(url, maxRedirects = 5, headers = {}) {
|
|
|
2891
3024
|
}
|
|
2892
3025
|
if (res.statusCode !== 200) {
|
|
2893
3026
|
res.resume();
|
|
2894
|
-
return reject(
|
|
3027
|
+
return reject(isGitHubApiUrl(url)
|
|
3028
|
+
? new GitHubHttpError(res.statusCode ?? 0, headers, res.headers)
|
|
3029
|
+
: new HttpStatusError(res.statusCode ?? 0, url));
|
|
2895
3030
|
}
|
|
2896
3031
|
const chunks = [];
|
|
2897
3032
|
res.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -2967,11 +3102,21 @@ prefetchedRelease) {
|
|
|
2967
3102
|
else {
|
|
2968
3103
|
logSessionStart(`github-install ${tool.id}: fetching release metadata from ${spec.repo}`);
|
|
2969
3104
|
try {
|
|
2970
|
-
const body = await httpsGet(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, githubApiAuthHeaders());
|
|
3105
|
+
const body = await httpsGet(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, await githubApiAuthHeaders());
|
|
2971
3106
|
releaseJson = JSON.parse(body.toString("utf8"));
|
|
2972
3107
|
}
|
|
2973
3108
|
catch (err) {
|
|
2974
|
-
|
|
3109
|
+
const reason = err instanceof GitHubHttpError && err.anonymousRateLimitExhausted
|
|
3110
|
+
? "GitHub API rate limit exhausted; authenticate with `gh auth login` or set GITHUB_TOKEN/GH_TOKEN and retry"
|
|
3111
|
+
: `release fetch failed: ${err.message}`;
|
|
3112
|
+
if (err instanceof GitHubHttpError && err.anonymousRateLimitExhausted) {
|
|
3113
|
+
recordDegradationOnce({
|
|
3114
|
+
kind: "github-api-rate-limit",
|
|
3115
|
+
subject: tool.id,
|
|
3116
|
+
reason,
|
|
3117
|
+
});
|
|
3118
|
+
}
|
|
3119
|
+
logSessionStart(`github-install ${tool.id}: ${reason}`);
|
|
2975
3120
|
return undefined;
|
|
2976
3121
|
}
|
|
2977
3122
|
}
|
|
@@ -3524,15 +3669,25 @@ async function refreshGitHubManagedTool(tool, known) {
|
|
|
3524
3669
|
let response;
|
|
3525
3670
|
try {
|
|
3526
3671
|
response = await httpsGetWithMeta(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, {
|
|
3527
|
-
...githubApiAuthHeaders(),
|
|
3672
|
+
...(await githubApiAuthHeaders()),
|
|
3528
3673
|
...(known.etag ? { "If-None-Match": known.etag } : {}),
|
|
3529
3674
|
});
|
|
3530
3675
|
}
|
|
3531
3676
|
catch (err) {
|
|
3677
|
+
const reason = err instanceof GitHubHttpError && err.anonymousRateLimitExhausted
|
|
3678
|
+
? "GitHub API rate limit exhausted; authenticate with `gh auth login` or set GITHUB_TOKEN/GH_TOKEN and retry"
|
|
3679
|
+
: `release query failed: ${err.message}`;
|
|
3680
|
+
if (err instanceof GitHubHttpError && err.anonymousRateLimitExhausted) {
|
|
3681
|
+
recordDegradationOnce({
|
|
3682
|
+
kind: "github-api-rate-limit",
|
|
3683
|
+
subject: tool.id,
|
|
3684
|
+
reason,
|
|
3685
|
+
});
|
|
3686
|
+
}
|
|
3532
3687
|
return {
|
|
3533
3688
|
ok: false,
|
|
3534
3689
|
unchanged: true,
|
|
3535
|
-
reason
|
|
3690
|
+
reason,
|
|
3536
3691
|
};
|
|
3537
3692
|
}
|
|
3538
3693
|
if (response.statusCode === 304) {
|
|
@@ -4349,6 +4504,77 @@ export function pipScriptsDir(base, platform = installerPlatform()) {
|
|
|
4349
4504
|
return path.join(base, platform === "win32" ? "Scripts" : "bin");
|
|
4350
4505
|
}
|
|
4351
4506
|
const pipPep668LoggedRefusals = createGenerationMap("installer-pep668-log");
|
|
4507
|
+
/**
|
|
4508
|
+
* The constraints environment for `toolId` — `PIP_CONSTRAINT` **and**
|
|
4509
|
+
* `UV_CONSTRAINT`, both naming the same written file — or `{}` when its registry
|
|
4510
|
+
* entry declares no `pipConstraints` (#3311).
|
|
4511
|
+
*
|
|
4512
|
+
* Each resolver's own mechanism, not a pi-lens one, and BOTH are needed because
|
|
4513
|
+
* `installPipTool`'s first rung is pipx, which chooses its own resolver:
|
|
4514
|
+
* - `PIP_CONSTRAINT` is pip's environment form of `-c/--constraint`, and covers
|
|
4515
|
+
* pipx's pip backend, the pi-lens venv rung, `pip --user` and the
|
|
4516
|
+
* private-prefix rung.
|
|
4517
|
+
* - `UV_CONSTRAINT` is uv's ("Equivalent to the `--constraints` command-line
|
|
4518
|
+
* argument", uv 0.12.10 `crates/uv-static/src/env_vars.rs`, added in uv
|
|
4519
|
+
* 0.1.36) and covers pipx's uv backend — which pipx 1.17.6 makes the DEFAULT
|
|
4520
|
+
* "when uv is available … else 'pip'" (`pipx install --help`). That backend
|
|
4521
|
+
* ignores `PIP_CONSTRAINT` entirely, and its `--pip-args` translation covers
|
|
4522
|
+
* an allowlist (`--index-url`, `--extra-index-url`, `--find-links`,
|
|
4523
|
+
* `--trusted-host`, `--no-binary`, `--only-binary`, `--pre`, `--upgrade`,
|
|
4524
|
+
* `--no-cache-dir`) that does not include constraints — so neither the pip env
|
|
4525
|
+
* var nor a pip argv flag can reach it. Measured on pipx 1.17.6 + uv 0.12.10;
|
|
4526
|
+
* the transcripts are in the PR body (#3396 round 2).
|
|
4527
|
+
*
|
|
4528
|
+
* Both variables carry a WHITESPACE-SEPARATED LIST of files, so a path with a
|
|
4529
|
+
* space in it is not a path to either resolver: uv fails the install outright
|
|
4530
|
+
* (`error: File not found: …/space`, measured). The file therefore goes to the
|
|
4531
|
+
* first whitespace-free directory, and without one the install proceeds
|
|
4532
|
+
* unconstrained rather than broken — which the ladder's PATH verification then
|
|
4533
|
+
* judges on its merits.
|
|
4534
|
+
*
|
|
4535
|
+
* The file is (re)written from the registry on every install, so a constraint
|
|
4536
|
+
* that is edited or removed in the registry cannot be served from a stale file.
|
|
4537
|
+
* A write failure is recorded and the install proceeds unconstrained — the same
|
|
4538
|
+
* resolution as before this field existed.
|
|
4539
|
+
*/
|
|
4540
|
+
async function pipConstraintEnvFor(toolId) {
|
|
4541
|
+
const constraints = TOOLS.find((t) => t.id === toolId)?.pipConstraints;
|
|
4542
|
+
if (!constraints || constraints.length === 0)
|
|
4543
|
+
return {};
|
|
4544
|
+
const directories = [
|
|
4545
|
+
path.join(getGlobalPiLensDir(), "pip-constraints"),
|
|
4546
|
+
path.join(os.tmpdir(), "pi-lens-pip-constraints"),
|
|
4547
|
+
];
|
|
4548
|
+
const directory = directories.find((candidate) => !/\s/.test(candidate));
|
|
4549
|
+
if (!directory) {
|
|
4550
|
+
recordDegradationOnce({
|
|
4551
|
+
kind: "pip-constraint-path-unusable",
|
|
4552
|
+
subject: toolId,
|
|
4553
|
+
reason: `no whitespace-free directory for the constraints file (tried ${directories.join(", ")}); installing unconstrained`,
|
|
4554
|
+
});
|
|
4555
|
+
return {};
|
|
4556
|
+
}
|
|
4557
|
+
const file = path.join(directory, `${toolId}.txt`);
|
|
4558
|
+
try {
|
|
4559
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
4560
|
+
// The shared atomic seam (#1609), not a raw write: a second pi-lens process
|
|
4561
|
+
// may be reading this file as pip's `PIP_CONSTRAINT` while this one
|
|
4562
|
+
// rewrites it, and a torn read would silently under-constrain the install.
|
|
4563
|
+
await writeFileAtomicAsync(file, `${constraints.join("\n")}\n`, {
|
|
4564
|
+
bestEffort: false,
|
|
4565
|
+
});
|
|
4566
|
+
}
|
|
4567
|
+
catch (err) {
|
|
4568
|
+
recordDegradationOnce({
|
|
4569
|
+
kind: "pip-constraint-file-unwritable",
|
|
4570
|
+
subject: toolId,
|
|
4571
|
+
reason: `${file}: ${err instanceof Error ? err.message : String(err)}`,
|
|
4572
|
+
});
|
|
4573
|
+
return {};
|
|
4574
|
+
}
|
|
4575
|
+
logSessionStart(`auto-install pip ${toolId}: constraining resolution with ${constraints.join(", ")} (${file})`);
|
|
4576
|
+
return { PIP_CONSTRAINT: file, UV_CONSTRAINT: file };
|
|
4577
|
+
}
|
|
4352
4578
|
/**
|
|
4353
4579
|
* Install a pip package tool
|
|
4354
4580
|
*/
|
|
@@ -4363,6 +4589,10 @@ options = {}) {
|
|
|
4363
4589
|
try {
|
|
4364
4590
|
const isWindows = installerPlatform() === "win32";
|
|
4365
4591
|
const verb = options.upgrade ? ["install", "-U"] : ["install"];
|
|
4592
|
+
// Read from the registry entry rather than added to this function's
|
|
4593
|
+
// signature: every pip rung below, and pipx's OWN internal pip, is bounded
|
|
4594
|
+
// by one env var, so there is nothing per-call to thread through.
|
|
4595
|
+
const pipConstraintEnv = await pipConstraintEnvFor(toolId);
|
|
4366
4596
|
// Built from `pipCommandCandidates()` — the single source of truth this
|
|
4367
4597
|
// module and any other caller (the tool-smoke lane's toolchain-presence
|
|
4368
4598
|
// probe, #2661 review) share, rather than a second, independently
|
|
@@ -4397,16 +4627,28 @@ options = {}) {
|
|
|
4397
4627
|
});
|
|
4398
4628
|
return binaryPath;
|
|
4399
4629
|
};
|
|
4400
|
-
const run = (command, args, env) =>
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4630
|
+
const run = (command, args, env) => {
|
|
4631
|
+
// `pipConstraintEnv` is empty unless the entry declares
|
|
4632
|
+
// `pipConstraints`, and it is applied HERE — the single spawn seam every
|
|
4633
|
+
// rung of the ladder (pipx, venv, --user, private-prefix) goes through —
|
|
4634
|
+
// so no rung can be reached with the constraint missing. pipx forwards
|
|
4635
|
+
// it: its `run_subprocess` starts from `dict(os.environ)` and blocklists
|
|
4636
|
+
// only PYTHONPATH/__PYVENV_LAUNCHER__ (pipx 1.16.7 src/pipx/util.py
|
|
4637
|
+
// `_fix_subprocess_env`), so PIP_CONSTRAINT reaches the pip it drives.
|
|
4638
|
+
const spawnEnv = Object.keys(pipConstraintEnv).length > 0
|
|
4639
|
+
? { ...(env ?? process.env), ...pipConstraintEnv }
|
|
4640
|
+
: env;
|
|
4641
|
+
return safeSpawnAsync(command, args, {
|
|
4642
|
+
timeout: 120_000,
|
|
4643
|
+
ignoreAmbientSignal: true,
|
|
4644
|
+
lifetimeCoupled: true,
|
|
4645
|
+
cwd: resolveToolCwd("runner", toolId, getGlobalPiLensDir(), {
|
|
4646
|
+
cwd: getGlobalPiLensDir(),
|
|
4647
|
+
suppressTelemetry: true,
|
|
4648
|
+
}).cwd,
|
|
4649
|
+
...(spawnEnv ? { env: spawnEnv } : {}),
|
|
4650
|
+
});
|
|
4651
|
+
};
|
|
4410
4652
|
const addBinToPath = async (binDir) => {
|
|
4411
4653
|
try {
|
|
4412
4654
|
await fs.access(binDir);
|
|
@@ -4441,10 +4683,17 @@ options = {}) {
|
|
|
4441
4683
|
return undefined;
|
|
4442
4684
|
};
|
|
4443
4685
|
if (await isCommandAvailable("pipx")) {
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4686
|
+
// `--force` on the install verb (#3312): this function runs ONLY when the
|
|
4687
|
+
// tool was not resolvable, yet plain `pipx install <pkg>` over an
|
|
4688
|
+
// existing venv exits 0 while printing "not modifying existing
|
|
4689
|
+
// installation. Pass '--force' …" — so an install that changes nothing
|
|
4690
|
+
// reports success and hands back the same unusable launcher. That is the
|
|
4691
|
+
// #2638/#2661 shape (an install that installs nothing reporting like a
|
|
4692
|
+
// real one) and it also swallows a changed package spec, e.g. a venv
|
|
4693
|
+
// created before `cmakelang` grew its `[yaml]` extra above.
|
|
4694
|
+
const result = await run("pipx", options.upgrade
|
|
4695
|
+
? ["upgrade", packageName]
|
|
4696
|
+
: ["install", "--force", packageName]);
|
|
4448
4697
|
const error = (result.error?.message ?? result.stderr).trim();
|
|
4449
4698
|
if (result.status === 0) {
|
|
4450
4699
|
const location = await run("pipx", [
|
|
@@ -4523,9 +4772,9 @@ options = {}) {
|
|
|
4523
4772
|
resolve("");
|
|
4524
4773
|
return;
|
|
4525
4774
|
}
|
|
4526
|
-
|
|
4527
|
-
probe.stdout?.on("data", (data) => (
|
|
4528
|
-
probe.on("exit", (code) => resolve(code
|
|
4775
|
+
const stdout = createBoundedOutputSink();
|
|
4776
|
+
probe.stdout?.on("data", (data) => stdout.append(data));
|
|
4777
|
+
probe.on("exit", (code) => resolve(userBaseProbeResult(candidate.command, code, stdout)));
|
|
4529
4778
|
probe.on("error", () => resolve(""));
|
|
4530
4779
|
});
|
|
4531
4780
|
const binaryPath = base
|