ctxman 3.0.0 → 3.1.1
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/.ctxman/llm-profiles.json +1067 -39
- package/.ctxman/profiles/backend-team.json +13 -0
- package/.ctxman/profiles/default.json +12 -0
- package/.ctxman/profiles/devops-team.json +21 -0
- package/.ctxman/profiles/frontend-team.json +20 -0
- package/.ctxman/templates/bug-fix.json +17 -0
- package/.ctxman/templates/code-review.json +9 -0
- package/.ctxman/templates/documentation.json +9 -0
- package/.ctxman/templates/feature.json +9 -0
- package/.ctxman/templates/full-context.json +9 -0
- package/.ctxman/templates/refactoring.json +18 -0
- package/.ctxman/templates/testing.json +9 -0
- package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
- package/LICENSE +21 -0
- package/README.md +381 -136
- package/bin/cli.js +1880 -529
- package/bin/cm-ask.js +18 -67
- package/bin/cm-audit.js +121 -0
- package/bin/cm-count.js +151 -0
- package/bin/cm-gitingest.js +128 -115
- package/bin/cm-map.js +242 -0
- package/bin/cm-mcp.js +293 -0
- package/bin/cm-pack.js +405 -0
- package/bin/cm-policy.js +170 -0
- package/bin/cm-pr.js +248 -0
- package/bin/cm-search.js +178 -0
- package/bin/cm-update.js +128 -128
- package/bin/cm-why.js +197 -0
- package/bin/mcp-server.js +8 -24
- package/ctxman.js +237 -183
- package/examples/README.md +19 -4
- package/examples/github-actions/ctxman-check.yml +49 -0
- package/examples/wizard-profiles/security-audit/profile.json +7 -1
- package/index.d.ts +1422 -0
- package/index.js +55 -31
- package/lib/agents/pr-failure-agent.js +48 -45
- package/lib/analyzers/ai-suggester.js +451 -0
- package/lib/analyzers/dependency-scanner.js +505 -0
- package/lib/analyzers/duplicate-detector.js +264 -0
- package/lib/analyzers/go-method-analyzer.js +151 -110
- package/lib/analyzers/import-tracker.js +431 -0
- package/lib/analyzers/method-analyzer.js +1055 -434
- package/lib/analyzers/performance-dashboard.js +687 -0
- package/lib/analyzers/token-calculator.js +895 -504
- package/lib/analyzers/unused-exports-detector.js +336 -0
- package/lib/api/mcp/install.js +424 -0
- package/lib/api/mcp/prompts.js +281 -281
- package/lib/api/mcp/resources.js +300 -241
- package/lib/api/mcp/sandbox.js +89 -0
- package/lib/api/mcp/server.js +772 -312
- package/lib/api/mcp/start.js +125 -0
- package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
- package/lib/api/rest/server.js +105 -28
- package/lib/cache/CacheManager.js +9 -17
- package/lib/core/Analyzer.js +16 -10
- package/lib/core/ContextBuilder.js +19 -28
- package/lib/core/Reporter.js +15 -14
- package/lib/core/Scanner.js +81 -12
- package/lib/count/index.js +100 -0
- package/lib/formatters/format-registry.js +394 -372
- package/lib/formatters/gitingest-formatter.js +656 -621
- package/lib/formatters/toon-formatter-v1.3.js +637 -632
- package/lib/formatters/toon-formatter.js +239 -240
- package/lib/integrations/git/BlameTracker.js +4 -4
- package/lib/integrations/git/DiffAnalyzer.js +38 -31
- package/lib/integrations/git/GitClient.js +71 -53
- package/lib/lsp/server.js +311 -0
- package/lib/map/graph.js +299 -0
- package/lib/map/imports.js +233 -0
- package/lib/map/index.js +33 -0
- package/lib/map/rank.js +253 -0
- package/lib/map/render.js +346 -0
- package/lib/map/resolver.js +574 -0
- package/lib/pack/budget.js +111 -0
- package/lib/pack/code-lexer.js +360 -0
- package/lib/pack/compress.js +414 -0
- package/lib/pack/index.js +354 -0
- package/lib/pack/renderers/common.js +134 -0
- package/lib/pack/renderers/index.js +24 -0
- package/lib/pack/renderers/json.js +47 -0
- package/lib/pack/renderers/markdown.js +54 -0
- package/lib/pack/renderers/plain.js +38 -0
- package/lib/pack/renderers/xml.js +85 -0
- package/lib/pack/select.js +177 -0
- package/lib/pack/split.js +182 -0
- package/lib/pack/tree.js +35 -0
- package/lib/parsers/gitignore-parser.js +116 -107
- package/lib/parsers/method-filter-parser.js +50 -33
- package/lib/plugins/ExporterPlugin.js +3 -3
- package/lib/plugins/LanguagePlugin.js +3 -3
- package/lib/plugins/PluginManager.js +7 -13
- package/lib/policy/audit.js +312 -0
- package/lib/policy/enforce.js +125 -0
- package/lib/policy/ignore-files.js +169 -0
- package/lib/policy/index.js +53 -0
- package/lib/policy/policy.js +489 -0
- package/lib/pr/diff-parser.js +267 -0
- package/lib/pr/method-mapper.js +148 -0
- package/lib/pr/pr-context.js +829 -0
- package/lib/pr/pr-git.js +149 -0
- package/lib/pr/pr-renderer.js +644 -0
- package/lib/pr/related-tests.js +296 -0
- package/lib/rag/DataSourcePlugin.js +5 -5
- package/lib/rag/EmbeddingProvider.js +19 -8
- package/lib/rag/Indexer.js +14 -5
- package/lib/rag/VectorStoreFactory.js +3 -3
- package/lib/rag/ask.js +122 -0
- package/lib/rag/sources/FileDataSource.js +12 -9
- package/lib/rag/stores/BaseVectorStore.js +5 -5
- package/lib/rag/stores/ChromaDBStore.js +10 -10
- package/lib/rag/stores/LanceDBStore.js +29 -19
- package/lib/rag/stores/PineconeStore.js +11 -9
- package/lib/search/bm25.js +433 -0
- package/lib/search/code-units.js +201 -0
- package/lib/search/symbols.js +189 -0
- package/lib/ui/dashboard.js +120 -89
- package/lib/ui/index.js +1 -6
- package/lib/ui/init-wizard-ui.js +550 -0
- package/lib/ui/progress-bar.js +94 -88
- package/lib/ui/select-input.js +40 -32
- package/lib/ui/template-selector.js +99 -0
- package/lib/ui/wizard.js +374 -329
- package/lib/utils/claude-token-counter.js +151 -0
- package/lib/utils/cli-output.js +17 -0
- package/lib/utils/clipboard-utils.js +49 -50
- package/lib/utils/config-utils.js +81 -66
- package/lib/utils/context-versioning.js +532 -0
- package/lib/utils/error-handler.js +112 -112
- package/lib/utils/file-utils.js +153 -69
- package/lib/utils/format-converter.js +356 -283
- package/lib/utils/git-utils.js +366 -294
- package/lib/utils/llm-cost-estimator.js +281 -0
- package/lib/utils/llm-detector.js +242 -78
- package/lib/utils/logger.js +246 -237
- package/lib/utils/messages.js +355 -0
- package/lib/utils/multi-repo-manager.js +364 -0
- package/lib/utils/profile-manager.js +380 -0
- package/lib/utils/safe-read.js +61 -0
- package/lib/utils/secret-redactor.js +61 -0
- package/lib/utils/snapshot-manager.js +679 -0
- package/lib/utils/template-manager.js +378 -0
- package/lib/utils/token-utils.js +201 -170
- package/lib/utils/tokenizer-adapter.js +421 -377
- package/lib/utils/updater.js +539 -506
- package/lib/watch/FileWatcher.js +12 -8
- package/lib/watch/IncrementalAnalyzer.js +7 -7
- package/lib/why/coupling.js +81 -0
- package/lib/why/errors.js +23 -0
- package/lib/why/git.js +169 -0
- package/lib/why/history.js +108 -0
- package/lib/why/index.js +720 -0
- package/lib/why/ownership.js +79 -0
- package/lib/why/paths.js +207 -0
- package/lib/why/references.js +259 -0
- package/lib/why/render.js +722 -0
- package/lib/why/search.js +358 -0
- package/lib/why/target.js +389 -0
- package/lib/wizards/config-generator.js +257 -0
- package/lib/wizards/project-detector.js +572 -0
- package/package.json +44 -10
- package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
- package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull-request / branch review context (`ctxman pr`)
|
|
3
|
+
*
|
|
4
|
+
* Collects everything a reviewer (human or model) needs about a branch in one document:
|
|
5
|
+
* a summary, the commit log, the changed files, the full source of every method the diff
|
|
6
|
+
* touches (plus the methods it deletes), the unified diff, related tests and, on request,
|
|
7
|
+
* the changed files' full contents, followed by the question or instructions. A token
|
|
8
|
+
* budget drops the least important material first.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { isValidGitRef } from '../integrations/git/GitClient.js';
|
|
14
|
+
import DiffAnalyzer from '../integrations/git/DiffAnalyzer.js';
|
|
15
|
+
import { Scanner } from '../core/Scanner.js';
|
|
16
|
+
import FileUtils from '../utils/file-utils.js';
|
|
17
|
+
import TokenUtils from '../utils/token-utils.js';
|
|
18
|
+
import { redactSecrets } from '../utils/secret-redactor.js';
|
|
19
|
+
import { readFileChecked } from '../utils/safe-read.js';
|
|
20
|
+
import PrGit, { bufferToText } from './pr-git.js';
|
|
21
|
+
import {
|
|
22
|
+
parseNameStatus,
|
|
23
|
+
parseNumstat,
|
|
24
|
+
splitDiff,
|
|
25
|
+
parseHunks,
|
|
26
|
+
syntheticAddedDiff,
|
|
27
|
+
countLines,
|
|
28
|
+
} from './diff-parser.js';
|
|
29
|
+
import {
|
|
30
|
+
supportsMethods,
|
|
31
|
+
extractMethodRanges,
|
|
32
|
+
findTouchedMethods,
|
|
33
|
+
findDeletedMethods,
|
|
34
|
+
} from './method-mapper.js';
|
|
35
|
+
import { findRelatedTests, isTestFile } from './related-tests.js';
|
|
36
|
+
import { renderPrContext, renderItem } from './pr-renderer.js';
|
|
37
|
+
|
|
38
|
+
export const PR_STYLES = ['markdown', 'xml', 'json'];
|
|
39
|
+
|
|
40
|
+
const DEFAULTS = {
|
|
41
|
+
base: null,
|
|
42
|
+
head: null,
|
|
43
|
+
staged: false,
|
|
44
|
+
working: false,
|
|
45
|
+
unified: 3,
|
|
46
|
+
diff: true,
|
|
47
|
+
withTests: false,
|
|
48
|
+
withFiles: false,
|
|
49
|
+
ask: null,
|
|
50
|
+
instructions: null,
|
|
51
|
+
budget: null,
|
|
52
|
+
style: 'markdown',
|
|
53
|
+
redact: true,
|
|
54
|
+
maxFileBytes: 1024 * 1024,
|
|
55
|
+
maxRelatedTests: 50,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Test files read to look for imports and references: per file, and for all of them
|
|
59
|
+
const TEST_READ_LIMIT = 512 * 1024;
|
|
60
|
+
const TEST_READ_TOTAL = 64 * 1024 * 1024;
|
|
61
|
+
// ctxman's own run-time output (logs written by any ctxman command), never part of a change
|
|
62
|
+
const OWN_OUTPUT = /^\.ctxman\/(logs|temp|metrics)\//;
|
|
63
|
+
const FILTER_NAMES = {
|
|
64
|
+
gitignore: '.gitignore',
|
|
65
|
+
context: '.contextignore',
|
|
66
|
+
'context-include': '.contextinclude',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check and complete the options
|
|
71
|
+
* @param {object} options
|
|
72
|
+
*/
|
|
73
|
+
export function normalizePrOptions(options = {}) {
|
|
74
|
+
const defined = Object.fromEntries(
|
|
75
|
+
Object.entries(options).filter(([, value]) => value !== undefined)
|
|
76
|
+
);
|
|
77
|
+
const opts = { ...DEFAULTS, ...defined };
|
|
78
|
+
|
|
79
|
+
if (!PR_STYLES.includes(opts.style)) {
|
|
80
|
+
throw new Error(`Unknown style ${JSON.stringify(opts.style)} (use markdown, xml or json)`);
|
|
81
|
+
}
|
|
82
|
+
for (const key of ['base', 'head']) {
|
|
83
|
+
if (opts[key] != null && !isValidGitRef(opts[key])) {
|
|
84
|
+
throw new Error(`Invalid git reference for ${key}: ${JSON.stringify(opts[key])}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (opts.staged && opts.working) {
|
|
88
|
+
throw new Error('--staged and --working cannot be used together');
|
|
89
|
+
}
|
|
90
|
+
if ((opts.staged || opts.working) && opts.head) {
|
|
91
|
+
throw new Error('--head cannot be used with --staged or --working');
|
|
92
|
+
}
|
|
93
|
+
if (!Number.isInteger(opts.unified) || opts.unified < 0 || opts.unified > 10000) {
|
|
94
|
+
throw new Error(`Invalid number of context lines: ${JSON.stringify(opts.unified)}`);
|
|
95
|
+
}
|
|
96
|
+
if (opts.budget != null && (!Number.isInteger(opts.budget) || opts.budget <= 0)) {
|
|
97
|
+
throw new Error(`Invalid token budget: ${JSON.stringify(opts.budget)}`);
|
|
98
|
+
}
|
|
99
|
+
return opts;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Remote default branch (origin/HEAD), else main, else master */
|
|
103
|
+
function detectBaseRef(git) {
|
|
104
|
+
const originHead = git.run(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], {
|
|
105
|
+
allowFail: true,
|
|
106
|
+
});
|
|
107
|
+
const candidates = [originHead && originHead.trim(), 'main', 'master'].filter(Boolean);
|
|
108
|
+
for (const ref of candidates) {
|
|
109
|
+
if (isValidGitRef(ref) && git.resolveCommit(ref)) return ref;
|
|
110
|
+
}
|
|
111
|
+
throw new Error('Could not find a base branch (origin/HEAD, main or master); pass --base <ref>');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Resolve what is compared with what */
|
|
115
|
+
function resolveRange(git, opts) {
|
|
116
|
+
const mode = opts.staged ? 'staged' : opts.working ? 'working' : 'range';
|
|
117
|
+
const resolve = (ref, what) => {
|
|
118
|
+
const sha = git.resolveCommit(ref);
|
|
119
|
+
if (!sha) throw new Error(`Unknown ${what} revision ${JSON.stringify(ref)}`);
|
|
120
|
+
return sha;
|
|
121
|
+
};
|
|
122
|
+
const mergeBaseOf = (a, b) => {
|
|
123
|
+
const out = git.run(['merge-base', a, b], { allowFail: true });
|
|
124
|
+
if (!out || !out.trim()) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
'Base and head have no common ancestor (in a shallow clone, fetch more history)'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return out.trim();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (mode === 'range') {
|
|
133
|
+
const headRef = opts.head || 'HEAD';
|
|
134
|
+
const headSha = resolve(headRef, 'head');
|
|
135
|
+
const baseRef = opts.base || detectBaseRef(git);
|
|
136
|
+
const baseSha = resolve(baseRef, 'base');
|
|
137
|
+
let headName = headRef;
|
|
138
|
+
if (headRef === 'HEAD') {
|
|
139
|
+
const branch = git.run(['rev-parse', '--abbrev-ref', 'HEAD'], { allowFail: true });
|
|
140
|
+
if (branch && branch.trim() && branch.trim() !== 'HEAD') headName = branch.trim();
|
|
141
|
+
}
|
|
142
|
+
const mergeBase = mergeBaseOf(baseSha, headSha);
|
|
143
|
+
return { mode, baseRef, baseSha, headRef: headName, headSha, from: mergeBase, mergeBase };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const headSha = git.resolveCommit('HEAD');
|
|
147
|
+
if (!headSha) throw new Error('The repository has no commits yet');
|
|
148
|
+
const baseRef = opts.base || 'HEAD';
|
|
149
|
+
const baseSha = resolve(baseRef, 'base');
|
|
150
|
+
const mergeBase = baseSha === headSha ? headSha : mergeBaseOf(baseSha, headSha);
|
|
151
|
+
return {
|
|
152
|
+
mode,
|
|
153
|
+
baseRef,
|
|
154
|
+
baseSha,
|
|
155
|
+
headRef: mode === 'staged' ? 'index' : 'working tree',
|
|
156
|
+
headSha,
|
|
157
|
+
from: mergeBase,
|
|
158
|
+
mergeBase,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Commits from `from` to `to`, oldest first */
|
|
163
|
+
function readCommits(git, from, to, pathspec) {
|
|
164
|
+
if (from === to) return [];
|
|
165
|
+
const out = git.run([
|
|
166
|
+
'log',
|
|
167
|
+
'--reverse',
|
|
168
|
+
'--no-color',
|
|
169
|
+
'--format=%x1e%H%x00%h%x00%an%x00%aI%x00%s%x00%b',
|
|
170
|
+
`${from}..${to}`,
|
|
171
|
+
'--',
|
|
172
|
+
...pathspec,
|
|
173
|
+
]);
|
|
174
|
+
return out
|
|
175
|
+
.split('\x1e')
|
|
176
|
+
.filter((record) => record.trim())
|
|
177
|
+
.map((record) => {
|
|
178
|
+
const [sha, short, author, date, subject, body = ''] = record.split('\0');
|
|
179
|
+
return { sha, short, author, date: date.slice(0, 10), subject, body: body.trim() };
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const isInside = (root, target) => {
|
|
184
|
+
const relative = path.relative(root, target);
|
|
185
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Read a working-tree file without following links out of the repository */
|
|
189
|
+
function readWorkingFile(realRoot, relativePath, maxBytes) {
|
|
190
|
+
const fullPath = path.resolve(realRoot, relativePath);
|
|
191
|
+
if (!isInside(realRoot, fullPath)) return { status: 'missing' };
|
|
192
|
+
const read = readFileChecked(fullPath, { maxBytes, followLinks: false, within: realRoot });
|
|
193
|
+
if (read.status === 'not-file') return { status: 'not-blob' };
|
|
194
|
+
if (read.status === 'too-large') return { status: 'too-large', size: read.stat.size };
|
|
195
|
+
if (read.status !== 'ok') return { status: 'missing' };
|
|
196
|
+
return { status: 'ok', text: bufferToText(read.buffer), size: read.buffer.length };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Why the context filters keep a file's content out, or null */
|
|
200
|
+
function createExclusionCheck(rootPath, repoRoot) {
|
|
201
|
+
const scanner = new Scanner(rootPath);
|
|
202
|
+
const parser = scanner.gitIgnore;
|
|
203
|
+
return (repoRelativePath) => {
|
|
204
|
+
const relative = path
|
|
205
|
+
.relative(rootPath, path.join(repoRoot, repoRelativePath))
|
|
206
|
+
.split(path.sep)
|
|
207
|
+
.join('/');
|
|
208
|
+
if (relative.startsWith('../')) return 'scope';
|
|
209
|
+
const segments = relative.split('/');
|
|
210
|
+
if (segments.slice(0, -1).some((name) => FileUtils.isSkippedDirectory(name))) {
|
|
211
|
+
return 'a skipped directory';
|
|
212
|
+
}
|
|
213
|
+
// The scanner never enters an ignored directory, so check the parents too (with an
|
|
214
|
+
// include file, directories are only traversal hints, so only the file counts)
|
|
215
|
+
const paths = parser.hasIncludeFile
|
|
216
|
+
? [relative]
|
|
217
|
+
: [...segments.slice(0, -1).map((_, i) => segments.slice(0, i + 1).join('/')), relative];
|
|
218
|
+
for (const candidate of paths) {
|
|
219
|
+
if (scanner.shouldIgnore(candidate, path.join(rootPath, candidate))) {
|
|
220
|
+
return FILTER_NAMES[parser._lastIgnoreReason] || 'context filters';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether the export policy denies a repository path. Denied files are left out entirely,
|
|
229
|
+
* as the scanner does, so neither their content nor their name reaches the output
|
|
230
|
+
*/
|
|
231
|
+
function createDenyCheck(rootPath, repoRoot) {
|
|
232
|
+
const scanner = new Scanner(rootPath);
|
|
233
|
+
// Load now: an invalid policy fails the command instead of being skipped
|
|
234
|
+
scanner.getPolicy();
|
|
235
|
+
return (repoRelativePath) => {
|
|
236
|
+
if (!repoRelativePath) return false;
|
|
237
|
+
const relative = path
|
|
238
|
+
.relative(rootPath, path.join(repoRoot, repoRelativePath))
|
|
239
|
+
.split(path.sep)
|
|
240
|
+
.join('/');
|
|
241
|
+
return !relative.startsWith('../') && scanner.isDenied(relative);
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Labels for what the budget dropped, grouped by kind */
|
|
246
|
+
function describeDropped(dropped) {
|
|
247
|
+
const list = (items) => {
|
|
248
|
+
const shown = items.slice(0, 5).join(', ');
|
|
249
|
+
return items.length > 5 ? `${shown}, +${items.length - 5} more` : shown;
|
|
250
|
+
};
|
|
251
|
+
const labels = [];
|
|
252
|
+
if (dropped.contents.length) {
|
|
253
|
+
labels.push(`full contents of ${dropped.contents.length} file(s) (${list(dropped.contents)})`);
|
|
254
|
+
}
|
|
255
|
+
if (dropped.testContents.length) {
|
|
256
|
+
labels.push(
|
|
257
|
+
`contents of ${dropped.testContents.length} related test(s) (${list(dropped.testContents)})`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (dropped.tests.length) labels.push(`${dropped.tests.length} related test path(s)`);
|
|
261
|
+
if (dropped.diff.length) {
|
|
262
|
+
labels.push(`diff of ${dropped.diff.length} file(s) (${list(dropped.diff)})`);
|
|
263
|
+
}
|
|
264
|
+
if (dropped.methodSources.length) {
|
|
265
|
+
labels.push(
|
|
266
|
+
`source of ${dropped.methodSources.length} changed method(s) (${list(dropped.methodSources)})`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (dropped.methods) labels.push('the changed methods list');
|
|
270
|
+
if (dropped.commitBodies) labels.push('commit message bodies');
|
|
271
|
+
if (dropped.commits) labels.push('the commit log');
|
|
272
|
+
if (dropped.files) labels.push('the changed files list');
|
|
273
|
+
return labels;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build the review context for a branch, the staged changes or the working tree
|
|
278
|
+
* @param {string} [root=process.cwd()] - Project directory (inside a git work tree); a
|
|
279
|
+
* subdirectory limits the change to that directory
|
|
280
|
+
* @param {object} [options]
|
|
281
|
+
* @param {string} [options.base] - Base revision (default: origin/HEAD, else main, else master;
|
|
282
|
+
* HEAD with staged/working)
|
|
283
|
+
* @param {string} [options.head='HEAD'] - Head revision
|
|
284
|
+
* @param {boolean} [options.staged] - Compare the index with the base instead
|
|
285
|
+
* @param {boolean} [options.working] - Compare the working tree (with untracked files)
|
|
286
|
+
* @param {number} [options.unified=3] - Context lines in the diff
|
|
287
|
+
* @param {boolean} [options.diff=true] - Include the unified diff
|
|
288
|
+
* @param {boolean} [options.withTests] - Include related tests' contents
|
|
289
|
+
* @param {boolean} [options.withFiles] - Include changed files' full contents
|
|
290
|
+
* @param {string} [options.ask] - Question appended at the end
|
|
291
|
+
* @param {string} [options.instructions] - Instructions appended at the end
|
|
292
|
+
* @param {number} [options.budget] - Maximum tokens of the output
|
|
293
|
+
* @param {'markdown'|'xml'|'json'} [options.style='markdown']
|
|
294
|
+
* @param {boolean} [options.redact=true] - Mask secrets in contents, diff and commit messages
|
|
295
|
+
* @returns {Promise<object>} `output` (the rendered text), `tokens`, per-section `sections`
|
|
296
|
+
* token counts, the collected `commits`, `files`, `methods`, `relatedTests`, `stats`,
|
|
297
|
+
* `budget` (limit, dropped, over), `redactions` and `warnings`
|
|
298
|
+
*/
|
|
299
|
+
export async function buildPrContext(root = process.cwd(), options = {}) {
|
|
300
|
+
const opts = normalizePrOptions(options);
|
|
301
|
+
const rootPath = path.resolve(root);
|
|
302
|
+
if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
|
|
303
|
+
throw new Error(`Not a directory: ${rootPath}`);
|
|
304
|
+
}
|
|
305
|
+
const repoTop = PrGit.findRepoRoot(rootPath);
|
|
306
|
+
if (!repoTop) throw new Error(`Not inside a git repository: ${rootPath}`);
|
|
307
|
+
const repoRoot = fs.realpathSync(repoTop);
|
|
308
|
+
const realRootPath = fs.realpathSync(rootPath);
|
|
309
|
+
const scope = path.relative(repoRoot, realRootPath).split(path.sep).join('/');
|
|
310
|
+
const pathspec = scope ? [scope] : [];
|
|
311
|
+
const git = new PrGit(repoRoot);
|
|
312
|
+
const warnings = [];
|
|
313
|
+
|
|
314
|
+
// --- what is compared --------------------------------------------------------------
|
|
315
|
+
const range = resolveRange(git, opts);
|
|
316
|
+
const isDenied = createDenyCheck(realRootPath, repoRoot);
|
|
317
|
+
const denied = new Set();
|
|
318
|
+
const allowed = (repoPath, oldPath = null) => {
|
|
319
|
+
if (!isDenied(repoPath) && !isDenied(oldPath)) return true;
|
|
320
|
+
denied.add(repoPath);
|
|
321
|
+
return false;
|
|
322
|
+
};
|
|
323
|
+
// `rev-parse --short` takes one revision and `show` lists a repeated one once, so map
|
|
324
|
+
const shortSha = new Map(
|
|
325
|
+
git
|
|
326
|
+
.run(['show', '--no-patch', '--format=%H %h', range.baseSha, range.headSha, range.mergeBase])
|
|
327
|
+
.trim()
|
|
328
|
+
.split('\n')
|
|
329
|
+
.map((line) => line.split(' '))
|
|
330
|
+
);
|
|
331
|
+
const commits = readCommits(git, range.from, range.headSha, pathspec);
|
|
332
|
+
|
|
333
|
+
const diffArgs = ['--no-color', '--no-ext-diff', '--no-textconv', '-M'];
|
|
334
|
+
if (range.mode === 'staged') diffArgs.push('--cached');
|
|
335
|
+
diffArgs.push(range.from);
|
|
336
|
+
if (range.mode === 'range') diffArgs.push(range.headSha);
|
|
337
|
+
const tail = ['--', ...pathspec];
|
|
338
|
+
const nameStatus = parseNameStatus(
|
|
339
|
+
git.run(['diff', '--name-status', '-z', ...diffArgs, ...tail])
|
|
340
|
+
).filter((entry) => allowed(entry.path, entry.oldPath));
|
|
341
|
+
const numstat = parseNumstat(git.run(['diff', '--numstat', '-z', ...diffArgs, ...tail]));
|
|
342
|
+
const patch = git.run([
|
|
343
|
+
'diff',
|
|
344
|
+
`--unified=${opts.unified}`,
|
|
345
|
+
'--src-prefix=a/',
|
|
346
|
+
'--dst-prefix=b/',
|
|
347
|
+
...diffArgs,
|
|
348
|
+
...tail,
|
|
349
|
+
]);
|
|
350
|
+
const blocks = new Map();
|
|
351
|
+
for (const block of splitDiff(patch)) blocks.set(block.newPath ?? block.oldPath, block);
|
|
352
|
+
|
|
353
|
+
// --- head/base contents ---------------------------------------------------------------
|
|
354
|
+
const headCache = new Map();
|
|
355
|
+
const readHead = (paths, maxBytes = opts.maxFileBytes, maxTotalBytes = Infinity) => {
|
|
356
|
+
const found = new Map();
|
|
357
|
+
const todo = [];
|
|
358
|
+
for (const p of new Set(paths)) {
|
|
359
|
+
if (headCache.has(p)) found.set(p, headCache.get(p));
|
|
360
|
+
else todo.push(p);
|
|
361
|
+
}
|
|
362
|
+
let results = [];
|
|
363
|
+
if (range.mode === 'working') {
|
|
364
|
+
let total = 0;
|
|
365
|
+
results = todo.map((p) => {
|
|
366
|
+
const entry = readWorkingFile(repoRoot, p, Math.min(maxBytes, maxTotalBytes - total));
|
|
367
|
+
if (entry.status === 'ok') total += entry.size;
|
|
368
|
+
return entry;
|
|
369
|
+
});
|
|
370
|
+
} else if (todo.length) {
|
|
371
|
+
const prefix = range.mode === 'staged' ? ':' : `${range.headSha}:`;
|
|
372
|
+
results = git.readBlobs(
|
|
373
|
+
todo.map((p) => prefix + p),
|
|
374
|
+
{ maxBytes, maxTotalBytes }
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
todo.forEach((p, i) => {
|
|
378
|
+
found.set(p, results[i]);
|
|
379
|
+
// A size limit depends on the caller, so only definite answers are kept
|
|
380
|
+
if (results[i].status !== 'too-large') headCache.set(p, results[i]);
|
|
381
|
+
});
|
|
382
|
+
return found;
|
|
383
|
+
};
|
|
384
|
+
const readBase = (paths) => {
|
|
385
|
+
const results = git.readBlobs(
|
|
386
|
+
paths.map((p) => `${range.from}:${p}`),
|
|
387
|
+
{ maxBytes: opts.maxFileBytes }
|
|
388
|
+
);
|
|
389
|
+
return new Map(paths.map((p, i) => [p, results[i]]));
|
|
390
|
+
};
|
|
391
|
+
const textOf = (entry) => (entry && entry.status === 'ok' ? entry.text : null);
|
|
392
|
+
|
|
393
|
+
// --- changed files ----------------------------------------------------------------------
|
|
394
|
+
const isExcluded = createExclusionCheck(realRootPath, repoRoot);
|
|
395
|
+
const files = nameStatus.map((entry) => {
|
|
396
|
+
const stat = numstat.get(entry.path) || { additions: 0, deletions: 0, binary: false };
|
|
397
|
+
return {
|
|
398
|
+
...entry,
|
|
399
|
+
...stat,
|
|
400
|
+
untracked: false,
|
|
401
|
+
block: blocks.get(entry.path) || blocks.get(entry.oldPath) || null,
|
|
402
|
+
excluded: isExcluded(entry.path),
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
if (range.mode === 'working') {
|
|
406
|
+
const untracked = git
|
|
407
|
+
.run(['ls-files', '-z', '--others', '--exclude-standard', ...tail])
|
|
408
|
+
.split('\0')
|
|
409
|
+
.filter((p) => p && !OWN_OUTPUT.test(p) && allowed(p));
|
|
410
|
+
const contents = readHead(untracked);
|
|
411
|
+
for (const filePath of untracked) {
|
|
412
|
+
const entry = contents.get(filePath);
|
|
413
|
+
if (!entry || entry.status === 'missing' || entry.status === 'not-blob') continue;
|
|
414
|
+
const text = textOf(entry);
|
|
415
|
+
files.push({
|
|
416
|
+
status: 'A',
|
|
417
|
+
path: filePath,
|
|
418
|
+
oldPath: null,
|
|
419
|
+
similarity: null,
|
|
420
|
+
additions: text ? countLines(text) : 0,
|
|
421
|
+
deletions: 0,
|
|
422
|
+
binary: entry.status === 'ok' && text === null,
|
|
423
|
+
untracked: true,
|
|
424
|
+
block:
|
|
425
|
+
entry.status === 'ok'
|
|
426
|
+
? { oldPath: null, newPath: filePath, text: syntheticAddedDiff(filePath, text) }
|
|
427
|
+
: null,
|
|
428
|
+
excluded: isExcluded(filePath),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- redaction ------------------------------------------------------------------------
|
|
435
|
+
const redactions = {};
|
|
436
|
+
const redact = (text) => {
|
|
437
|
+
if (!opts.redact || typeof text !== 'string') return text;
|
|
438
|
+
const result = redactSecrets(text);
|
|
439
|
+
for (const type of result.findings) redactions[type] = (redactions[type] || 0) + 1;
|
|
440
|
+
return result.text;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// --- changed and deleted methods ------------------------------------------------------
|
|
444
|
+
const methodFiles = files.filter(
|
|
445
|
+
(f) => !f.excluded && !f.binary && (supportsMethods(f.path) || supportsMethods(f.oldPath || ''))
|
|
446
|
+
);
|
|
447
|
+
const headTexts = readHead(methodFiles.filter((f) => f.status !== 'D').map((f) => f.path));
|
|
448
|
+
const withBase = new Set(
|
|
449
|
+
methodFiles.filter((f) => f.oldPath && f.status !== 'A' && f.status !== 'C')
|
|
450
|
+
);
|
|
451
|
+
const baseTexts = readBase([...withBase].map((f) => f.oldPath));
|
|
452
|
+
const methods = [];
|
|
453
|
+
const deletedMethods = [];
|
|
454
|
+
let tooLarge = 0;
|
|
455
|
+
for (const file of methodFiles) {
|
|
456
|
+
const headEntry = file.status === 'D' ? null : headTexts.get(file.path);
|
|
457
|
+
const baseEntry = withBase.has(file) ? baseTexts.get(file.oldPath) : null;
|
|
458
|
+
if (headEntry?.status === 'too-large' || baseEntry?.status === 'too-large') tooLarge++;
|
|
459
|
+
const head = textOf(headEntry);
|
|
460
|
+
const base = textOf(baseEntry);
|
|
461
|
+
const headMethods =
|
|
462
|
+
head != null && supportsMethods(file.path) ? extractMethodRanges(head, file.path) : [];
|
|
463
|
+
const baseMethods =
|
|
464
|
+
base != null && supportsMethods(file.oldPath) ? extractMethodRanges(base, file.oldPath) : [];
|
|
465
|
+
const baseNames = new Set(baseMethods.map((m) => m.name));
|
|
466
|
+
const hunks = file.block
|
|
467
|
+
? parseHunks(file.block.text)
|
|
468
|
+
: { addedLines: [], deletionAnchors: [] };
|
|
469
|
+
for (const method of findTouchedMethods(headMethods, hunks)) {
|
|
470
|
+
methods.push({
|
|
471
|
+
file: file.path,
|
|
472
|
+
name: method.name,
|
|
473
|
+
start: method.start,
|
|
474
|
+
end: method.end,
|
|
475
|
+
change: baseNames.has(method.name) ? 'modified' : 'added',
|
|
476
|
+
changedLines: method.changedLines,
|
|
477
|
+
source: redact(method.source),
|
|
478
|
+
sourceDropped: false,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
const deleted =
|
|
482
|
+
file.status === 'D' ? baseMethods : findDeletedMethods(baseMethods, headMethods, head ?? '');
|
|
483
|
+
for (const method of deleted) {
|
|
484
|
+
deletedMethods.push({
|
|
485
|
+
file: file.oldPath,
|
|
486
|
+
name: method.name,
|
|
487
|
+
start: method.start,
|
|
488
|
+
end: method.end,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (tooLarge) {
|
|
493
|
+
warnings.push(
|
|
494
|
+
`${tooLarge} file(s) over ${opts.maxFileBytes} bytes were skipped for method analysis`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// --- related tests ----------------------------------------------------------------------
|
|
499
|
+
let candidates;
|
|
500
|
+
if (range.mode === 'range') {
|
|
501
|
+
candidates = git.run(['ls-tree', '-r', '-z', '--name-only', range.headSha, ...tail]);
|
|
502
|
+
} else if (range.mode === 'staged') {
|
|
503
|
+
candidates = git.run(['ls-files', '-z', '--cached', ...tail]);
|
|
504
|
+
} else {
|
|
505
|
+
candidates = git.run(['ls-files', '-z', '--cached', '--others', '--exclude-standard', ...tail]);
|
|
506
|
+
}
|
|
507
|
+
candidates = [...new Set(candidates.split('\0').filter((p) => p && !isDenied(p)))];
|
|
508
|
+
const testTexts = new Map();
|
|
509
|
+
const readContents = (paths) => {
|
|
510
|
+
const entries = readHead(paths, TEST_READ_LIMIT, TEST_READ_TOTAL);
|
|
511
|
+
for (const [p, entry] of entries) testTexts.set(p, textOf(entry));
|
|
512
|
+
return new Map([...entries].map(([p, entry]) => [p, textOf(entry)]));
|
|
513
|
+
};
|
|
514
|
+
const allRelated = findRelatedTests({
|
|
515
|
+
candidates,
|
|
516
|
+
changedFiles: files,
|
|
517
|
+
// Helpers changed inside test files are not the code under test
|
|
518
|
+
methodNames: [...methods, ...deletedMethods]
|
|
519
|
+
.filter((m) => !isTestFile(m.file))
|
|
520
|
+
.map((m) => m.name),
|
|
521
|
+
readContents,
|
|
522
|
+
});
|
|
523
|
+
const related = allRelated.slice(0, opts.maxRelatedTests);
|
|
524
|
+
if (opts.withTests) {
|
|
525
|
+
const missing = related.map((t) => t.path).filter((p) => !testTexts.has(p));
|
|
526
|
+
if (missing.length) readContents(missing);
|
|
527
|
+
}
|
|
528
|
+
const tests = related.map((test) => {
|
|
529
|
+
const excluded = isExcluded(test.path);
|
|
530
|
+
const content = opts.withTests && !excluded ? (testTexts.get(test.path) ?? null) : null;
|
|
531
|
+
return {
|
|
532
|
+
...test,
|
|
533
|
+
excluded,
|
|
534
|
+
content: typeof content === 'string' ? redact(content) : null,
|
|
535
|
+
contentDropped: false,
|
|
536
|
+
dropped: false,
|
|
537
|
+
};
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// --- full contents ----------------------------------------------------------------------
|
|
541
|
+
const contents = [];
|
|
542
|
+
if (opts.withFiles) {
|
|
543
|
+
const wanted = files.filter((f) => f.status !== 'D');
|
|
544
|
+
const entries = readHead(wanted.filter((f) => !f.excluded).map((f) => f.path));
|
|
545
|
+
for (const file of wanted) {
|
|
546
|
+
if (file.excluded) {
|
|
547
|
+
contents.push({ path: file.path, content: null, note: `excluded by ${file.excluded}` });
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
const entry = entries.get(file.path);
|
|
551
|
+
const text = textOf(entry);
|
|
552
|
+
let note = null;
|
|
553
|
+
if (entry?.status === 'too-large') note = `larger than ${opts.maxFileBytes} bytes`;
|
|
554
|
+
else if (entry?.status === 'ok' && text === null) note = 'binary';
|
|
555
|
+
else if (text === null) note = 'not readable';
|
|
556
|
+
contents.push({ path: file.path, content: redact(text), note, dropped: false });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// --- model --------------------------------------------------------------------------------
|
|
561
|
+
const byStatus = {};
|
|
562
|
+
for (const file of files) byStatus[file.status] = (byStatus[file.status] || 0) + 1;
|
|
563
|
+
const impact = new DiffAnalyzer(repoRoot).calculateImpact(files.map((f) => f.path));
|
|
564
|
+
const model = {
|
|
565
|
+
mode: range.mode,
|
|
566
|
+
base: { ref: range.baseRef, sha: range.baseSha, short: shortSha.get(range.baseSha) },
|
|
567
|
+
head:
|
|
568
|
+
range.mode === 'range'
|
|
569
|
+
? { ref: range.headRef, sha: range.headSha, short: shortSha.get(range.headSha) }
|
|
570
|
+
: { ref: range.headRef, sha: null, short: null },
|
|
571
|
+
mergeBase: { sha: range.mergeBase, short: shortSha.get(range.mergeBase) },
|
|
572
|
+
scope,
|
|
573
|
+
impact: { level: impact.level, score: impact.score },
|
|
574
|
+
stats: {
|
|
575
|
+
commits: commits.length,
|
|
576
|
+
files: files.length,
|
|
577
|
+
byStatus,
|
|
578
|
+
additions: files.reduce((sum, f) => sum + f.additions, 0),
|
|
579
|
+
deletions: files.reduce((sum, f) => sum + f.deletions, 0),
|
|
580
|
+
excluded: files.filter((f) => f.excluded).length,
|
|
581
|
+
changedMethods: methods.length,
|
|
582
|
+
addedMethods: methods.filter((m) => m.change === 'added').length,
|
|
583
|
+
deletedMethods: deletedMethods.length,
|
|
584
|
+
relatedTests: tests.length,
|
|
585
|
+
},
|
|
586
|
+
commits: commits.map((c) => ({ ...c, subject: redact(c.subject), body: redact(c.body) })),
|
|
587
|
+
files: files.map(
|
|
588
|
+
({
|
|
589
|
+
status,
|
|
590
|
+
path: filePath,
|
|
591
|
+
oldPath,
|
|
592
|
+
similarity,
|
|
593
|
+
additions,
|
|
594
|
+
deletions,
|
|
595
|
+
binary,
|
|
596
|
+
untracked,
|
|
597
|
+
excluded,
|
|
598
|
+
}) => ({
|
|
599
|
+
status,
|
|
600
|
+
path: filePath,
|
|
601
|
+
oldPath,
|
|
602
|
+
similarity,
|
|
603
|
+
additions,
|
|
604
|
+
deletions,
|
|
605
|
+
binary,
|
|
606
|
+
untracked,
|
|
607
|
+
excluded,
|
|
608
|
+
})
|
|
609
|
+
),
|
|
610
|
+
methods,
|
|
611
|
+
deletedMethods,
|
|
612
|
+
diffEnabled: opts.diff,
|
|
613
|
+
diffs: files
|
|
614
|
+
.filter((f) => f.block && !f.excluded)
|
|
615
|
+
.map((f) => ({ path: f.path, text: redact(f.block.text), dropped: false })),
|
|
616
|
+
tests,
|
|
617
|
+
withTests: opts.withTests,
|
|
618
|
+
withFiles: opts.withFiles,
|
|
619
|
+
contents,
|
|
620
|
+
instructions: opts.instructions,
|
|
621
|
+
ask: opts.ask,
|
|
622
|
+
budget: opts.budget ? { limit: opts.budget, dropped: [], over: false } : null,
|
|
623
|
+
tokens: null,
|
|
624
|
+
logBodiesDropped: false,
|
|
625
|
+
logDropped: false,
|
|
626
|
+
filesDropped: false,
|
|
627
|
+
methodsDropped: false,
|
|
628
|
+
};
|
|
629
|
+
if (denied.size) {
|
|
630
|
+
warnings.push(`${denied.size} changed file(s) denied by the export policy were left out`);
|
|
631
|
+
}
|
|
632
|
+
if (allRelated.length > related.length) {
|
|
633
|
+
warnings.push(
|
|
634
|
+
`${allRelated.length} related tests found; the first ${related.length} are listed`
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// --- render within the budget ------------------------------------------------------------
|
|
639
|
+
const count = (text) => TokenUtils.calculate(text, 'pr-context.md');
|
|
640
|
+
const dropped = {
|
|
641
|
+
contents: [],
|
|
642
|
+
testContents: [],
|
|
643
|
+
tests: [],
|
|
644
|
+
diff: [],
|
|
645
|
+
methodSources: [],
|
|
646
|
+
methods: false,
|
|
647
|
+
commitBodies: false,
|
|
648
|
+
commits: false,
|
|
649
|
+
files: false,
|
|
650
|
+
};
|
|
651
|
+
const measure = () => {
|
|
652
|
+
if (model.budget) model.budget.dropped = describeDropped(dropped);
|
|
653
|
+
model.tokens = null;
|
|
654
|
+
let { sections } = renderPrContext(model, opts.style);
|
|
655
|
+
const counts = {};
|
|
656
|
+
for (const [name, text] of Object.entries(sections)) counts[name] = count(text);
|
|
657
|
+
const sum = () => Object.values(counts).reduce((total, n) => total + n, 0);
|
|
658
|
+
model.tokens = { sections: counts, total: sum() };
|
|
659
|
+
({ sections } = renderPrContext(model, opts.style));
|
|
660
|
+
counts.summary = count(sections.summary);
|
|
661
|
+
model.tokens.total = sum();
|
|
662
|
+
const { output } = renderPrContext(model, opts.style);
|
|
663
|
+
return { output, tokens: count(output) };
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
let result = measure();
|
|
667
|
+
if (model.budget && result.tokens > model.budget.limit) {
|
|
668
|
+
const limit = model.budget.limit;
|
|
669
|
+
const steps = buildDropSteps(model, opts.style, dropped, count);
|
|
670
|
+
let estimate = result.tokens;
|
|
671
|
+
let stale = false;
|
|
672
|
+
for (const step of steps) {
|
|
673
|
+
if (stale || step.tokens == null || estimate <= limit) {
|
|
674
|
+
result = measure();
|
|
675
|
+
estimate = result.tokens;
|
|
676
|
+
stale = false;
|
|
677
|
+
if (estimate <= limit) break;
|
|
678
|
+
}
|
|
679
|
+
step.apply();
|
|
680
|
+
if (step.tokens == null) stale = true;
|
|
681
|
+
else estimate -= step.tokens;
|
|
682
|
+
}
|
|
683
|
+
result = measure();
|
|
684
|
+
model.budget.over = result.tokens > limit;
|
|
685
|
+
if (model.budget.over) {
|
|
686
|
+
result = measure();
|
|
687
|
+
warnings.push(
|
|
688
|
+
`The required sections alone take ${result.tokens} tokens, over the ${limit}-token budget`
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return {
|
|
694
|
+
output: result.output,
|
|
695
|
+
tokens: result.tokens,
|
|
696
|
+
style: opts.style,
|
|
697
|
+
mode: model.mode,
|
|
698
|
+
base: model.base,
|
|
699
|
+
head: model.head,
|
|
700
|
+
mergeBase: model.mergeBase,
|
|
701
|
+
scope,
|
|
702
|
+
stats: model.stats,
|
|
703
|
+
sections: model.tokens.sections,
|
|
704
|
+
budget: model.budget,
|
|
705
|
+
commits: model.commits,
|
|
706
|
+
files: model.files,
|
|
707
|
+
methods: { changed: methods, deleted: deletedMethods },
|
|
708
|
+
relatedTests: tests.map(({ path: testPath, reasons, changed, excluded }) => ({
|
|
709
|
+
path: testPath,
|
|
710
|
+
reasons,
|
|
711
|
+
changed,
|
|
712
|
+
excluded,
|
|
713
|
+
})),
|
|
714
|
+
redactions,
|
|
715
|
+
warnings,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* What the budget drops, least important first: full file contents, test contents, related
|
|
721
|
+
* test paths, per-file diffs, method sources, the methods list, commit bodies, the commit
|
|
722
|
+
* log, the file list. Item steps carry the tokens their text takes (largest items go first
|
|
723
|
+
* within a kind); whole-section steps have tokens null, so the caller measures before them.
|
|
724
|
+
*/
|
|
725
|
+
function buildDropSteps(model, style, dropped, count) {
|
|
726
|
+
const steps = [];
|
|
727
|
+
const bySize = (items) => items.sort((a, b) => b.tokens - a.tokens);
|
|
728
|
+
|
|
729
|
+
steps.push(
|
|
730
|
+
...bySize(
|
|
731
|
+
model.contents
|
|
732
|
+
.filter((item) => typeof item.content === 'string')
|
|
733
|
+
.map((item) => ({
|
|
734
|
+
tokens: count(renderItem('content', item, model, style)),
|
|
735
|
+
apply: () => {
|
|
736
|
+
item.dropped = true;
|
|
737
|
+
dropped.contents.push(item.path);
|
|
738
|
+
},
|
|
739
|
+
}))
|
|
740
|
+
)
|
|
741
|
+
);
|
|
742
|
+
if (model.withTests) {
|
|
743
|
+
steps.push(
|
|
744
|
+
...bySize(
|
|
745
|
+
model.tests
|
|
746
|
+
.filter((test) => typeof test.content === 'string')
|
|
747
|
+
.map((test) => ({
|
|
748
|
+
tokens: count(renderItem('testContent', test, model, style)),
|
|
749
|
+
apply: () => {
|
|
750
|
+
test.contentDropped = true;
|
|
751
|
+
dropped.testContents.push(test.path);
|
|
752
|
+
},
|
|
753
|
+
}))
|
|
754
|
+
)
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
for (const test of [...model.tests].reverse()) {
|
|
758
|
+
steps.push({
|
|
759
|
+
tokens: count(renderItem('testLine', test, model, style)),
|
|
760
|
+
apply: () => {
|
|
761
|
+
test.dropped = true;
|
|
762
|
+
dropped.tests.push(test.path);
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
if (model.diffEnabled) {
|
|
767
|
+
steps.push(
|
|
768
|
+
...bySize(
|
|
769
|
+
model.diffs.map((diff) => ({
|
|
770
|
+
tokens: count(diff.text),
|
|
771
|
+
apply: () => {
|
|
772
|
+
diff.dropped = true;
|
|
773
|
+
dropped.diff.push(diff.path);
|
|
774
|
+
},
|
|
775
|
+
}))
|
|
776
|
+
)
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
steps.push(
|
|
780
|
+
...bySize(
|
|
781
|
+
model.methods.map((method) => ({
|
|
782
|
+
tokens: count(method.source),
|
|
783
|
+
apply: () => {
|
|
784
|
+
method.sourceDropped = true;
|
|
785
|
+
dropped.methodSources.push(`${method.file}:${method.name}`);
|
|
786
|
+
},
|
|
787
|
+
}))
|
|
788
|
+
)
|
|
789
|
+
);
|
|
790
|
+
if (model.methods.length || model.deletedMethods.length) {
|
|
791
|
+
steps.push({
|
|
792
|
+
tokens: null,
|
|
793
|
+
apply: () => {
|
|
794
|
+
model.methodsDropped = true;
|
|
795
|
+
dropped.methods = true;
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
if (model.commits.some((commit) => commit.body)) {
|
|
800
|
+
steps.push({
|
|
801
|
+
tokens: null,
|
|
802
|
+
apply: () => {
|
|
803
|
+
model.logBodiesDropped = true;
|
|
804
|
+
dropped.commitBodies = true;
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
if (model.commits.length) {
|
|
809
|
+
steps.push({
|
|
810
|
+
tokens: null,
|
|
811
|
+
apply: () => {
|
|
812
|
+
model.logDropped = true;
|
|
813
|
+
dropped.commits = true;
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
if (model.files.length) {
|
|
818
|
+
steps.push({
|
|
819
|
+
tokens: null,
|
|
820
|
+
apply: () => {
|
|
821
|
+
model.filesDropped = true;
|
|
822
|
+
dropped.files = true;
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
return steps;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export default buildPrContext;
|