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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership of a target from `git blame --line-porcelain`: who wrote which share of the
|
|
3
|
+
* lines, and when each author last touched them
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { GitClient } from '../integrations/git/GitClient.js';
|
|
9
|
+
import { tryGit, utcDate } from './git.js';
|
|
10
|
+
|
|
11
|
+
const UNCOMMITTED = /^0+$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Blame summary for a line range (or the whole file)
|
|
15
|
+
* @param {string} top - Work tree root
|
|
16
|
+
* @param {object} query
|
|
17
|
+
* @param {string} query.path - Forward-slash path relative to `top`
|
|
18
|
+
* @param {number} [query.startLine]
|
|
19
|
+
* @param {number} [query.endLine]
|
|
20
|
+
* @param {string} [query.rev] - Blame this revision instead of the working tree
|
|
21
|
+
* @returns {{startLine: number|null, endLine: number|null, totalLines: number,
|
|
22
|
+
* lastChanged: string|null, authors: Array<{name: string, lines: number, share: number,
|
|
23
|
+
* commits: number, lastTouched: string|null}>}|null} null when blame fails
|
|
24
|
+
*/
|
|
25
|
+
export function collectOwnership(top, query) {
|
|
26
|
+
const args = ['blame', '--line-porcelain', '-w'];
|
|
27
|
+
if (query.startLine) args.push('-L', `${query.startLine},${query.endLine}`);
|
|
28
|
+
if (query.rev) args.push(query.rev);
|
|
29
|
+
args.push('--', query.path);
|
|
30
|
+
|
|
31
|
+
// Honour the conventional list of formatting-only commits, when the project has one
|
|
32
|
+
const ignoreRevs = path.join(top, '.git-blame-ignore-revs');
|
|
33
|
+
let output = null;
|
|
34
|
+
if (fs.existsSync(ignoreRevs)) {
|
|
35
|
+
output = tryGit(top, [
|
|
36
|
+
args[0],
|
|
37
|
+
'--ignore-revs-file',
|
|
38
|
+
'.git-blame-ignore-revs',
|
|
39
|
+
...args.slice(1),
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
output ??= tryGit(top, args);
|
|
43
|
+
if (output === null) return null;
|
|
44
|
+
|
|
45
|
+
// GitClient knows the porcelain format: one entry per line with commit, author, time
|
|
46
|
+
const lines = GitClient.prototype.parseBlameOutput.call(null, output);
|
|
47
|
+
const authors = new Map();
|
|
48
|
+
let lastChanged = 0;
|
|
49
|
+
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
const uncommitted = UNCOMMITTED.test(line.commit ?? '');
|
|
52
|
+
const name = uncommitted ? '(uncommitted)' : line.author || '(unknown)';
|
|
53
|
+
const entry = authors.get(name) ?? { name, lines: 0, commits: new Set(), lastTouched: 0 };
|
|
54
|
+
entry.lines++;
|
|
55
|
+
if (!uncommitted) {
|
|
56
|
+
entry.commits.add(line.commit);
|
|
57
|
+
entry.lastTouched = Math.max(entry.lastTouched, line.timestamp || 0);
|
|
58
|
+
lastChanged = Math.max(lastChanged, line.timestamp || 0);
|
|
59
|
+
}
|
|
60
|
+
authors.set(name, entry);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const total = lines.length;
|
|
64
|
+
return {
|
|
65
|
+
startLine: query.startLine ?? null,
|
|
66
|
+
endLine: query.endLine ?? null,
|
|
67
|
+
totalLines: total,
|
|
68
|
+
lastChanged: utcDate(lastChanged),
|
|
69
|
+
authors: [...authors.values()]
|
|
70
|
+
.map((entry) => ({
|
|
71
|
+
name: entry.name,
|
|
72
|
+
lines: entry.lines,
|
|
73
|
+
share: total ? Math.round((entry.lines / total) * 1000) / 1000 : 0,
|
|
74
|
+
commits: entry.commits.size,
|
|
75
|
+
lastTouched: utcDate(entry.lastTouched),
|
|
76
|
+
}))
|
|
77
|
+
.sort((a, b) => b.lines - a.lines || a.name.localeCompare(b.name)),
|
|
78
|
+
};
|
|
79
|
+
}
|
package/lib/why/paths.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path rules for `ctxman why`: keeping targets inside the project, applying the
|
|
3
|
+
* project's ignore files, and classifying files (tests, docs, ADRs, changelogs)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { Scanner } from '../core/Scanner.js';
|
|
9
|
+
import FileUtils from '../utils/file-utils.js';
|
|
10
|
+
import { WhyError } from './errors.js';
|
|
11
|
+
|
|
12
|
+
/** Extensions searched as prose: every line that mentions a term counts */
|
|
13
|
+
export const DOC_EXTENSIONS = new Set([
|
|
14
|
+
'.md',
|
|
15
|
+
'.markdown',
|
|
16
|
+
'.mdx',
|
|
17
|
+
'.adoc',
|
|
18
|
+
'.asciidoc',
|
|
19
|
+
'.asc',
|
|
20
|
+
'.rst',
|
|
21
|
+
'.txt',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve `file` (relative to `base`, or absolute) and check that it stays inside `root`,
|
|
26
|
+
* also after following symlinks
|
|
27
|
+
* @param {string} root - Real path of the project root
|
|
28
|
+
* @param {string} base - Directory relative paths start from
|
|
29
|
+
* @param {string} file
|
|
30
|
+
* @param {object} [options]
|
|
31
|
+
* @param {boolean} [options.mustExist=true] - false: a path that only exists in history
|
|
32
|
+
* @param {string} [options.label='the repository'] - What `root` is, for error messages
|
|
33
|
+
* @returns {{absolute: string, relative: string}} relative uses the OS separator
|
|
34
|
+
*/
|
|
35
|
+
export function resolveInsideRoot(
|
|
36
|
+
root,
|
|
37
|
+
base,
|
|
38
|
+
file,
|
|
39
|
+
{ mustExist = true, label = 'the repository' } = {}
|
|
40
|
+
) {
|
|
41
|
+
if (typeof file !== 'string' || file.trim() === '' || file.includes('\0')) {
|
|
42
|
+
throw new WhyError('INVALID_TARGET', 'A file path is required');
|
|
43
|
+
}
|
|
44
|
+
const absolute = path.resolve(base, file);
|
|
45
|
+
// The root itself, or a path above it (`..foo` is a valid name inside it)
|
|
46
|
+
const outside = (candidate) => {
|
|
47
|
+
const relative = path.relative(root, candidate);
|
|
48
|
+
return (
|
|
49
|
+
relative === '' ||
|
|
50
|
+
relative === '..' ||
|
|
51
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
52
|
+
path.isAbsolute(relative)
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
if (outside(absolute)) {
|
|
56
|
+
throw new WhyError('PATH_OUTSIDE_ROOT', `Path is outside ${label}: ${file}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const stat = fs.statSync(absolute, { throwIfNoEntry: false });
|
|
60
|
+
if (!stat && !mustExist) {
|
|
61
|
+
return { absolute, relative: path.relative(root, absolute) };
|
|
62
|
+
}
|
|
63
|
+
if (!stat) {
|
|
64
|
+
throw new WhyError('FILE_NOT_FOUND', `File not found: ${file}`);
|
|
65
|
+
}
|
|
66
|
+
if (!stat.isFile()) {
|
|
67
|
+
throw new WhyError('NOT_A_FILE', `Not a file: ${file}`);
|
|
68
|
+
}
|
|
69
|
+
const real = fs.realpathSync(absolute);
|
|
70
|
+
if (outside(real)) {
|
|
71
|
+
throw new WhyError('PATH_OUTSIDE_ROOT', `Path leads outside ${label}: ${file}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const relative = path.relative(root, real);
|
|
75
|
+
if (relative.split(path.sep)[0] === '.git') {
|
|
76
|
+
throw new WhyError('PATH_OUTSIDE_ROOT', `Path is inside the .git directory: ${file}`);
|
|
77
|
+
}
|
|
78
|
+
return { absolute: real, relative };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The scanner's rules as a predicate on project-relative paths: .gitignore,
|
|
83
|
+
* .contextignore / .contextinclude and the always-skipped directories, applied to every
|
|
84
|
+
* ancestor directory the way a scan would meet them
|
|
85
|
+
* @param {string} root
|
|
86
|
+
* @returns {(relativePath: string) => boolean} true when the path is excluded
|
|
87
|
+
*/
|
|
88
|
+
export function createPathFilter(root) {
|
|
89
|
+
const scanner = new Scanner(root);
|
|
90
|
+
// Load now: an invalid export policy fails the command instead of being skipped
|
|
91
|
+
scanner.getPolicy();
|
|
92
|
+
const cache = new Map();
|
|
93
|
+
|
|
94
|
+
const excludedDirectory = (relativeDir) => {
|
|
95
|
+
if (cache.has(relativeDir)) return cache.get(relativeDir);
|
|
96
|
+
const name = relativeDir.split('/').pop();
|
|
97
|
+
const parent = relativeDir.includes('/')
|
|
98
|
+
? relativeDir.slice(0, relativeDir.lastIndexOf('/'))
|
|
99
|
+
: '';
|
|
100
|
+
const excluded =
|
|
101
|
+
(parent !== '' && excludedDirectory(parent)) ||
|
|
102
|
+
FileUtils.isSkippedDirectory(name) ||
|
|
103
|
+
scanner.shouldIgnore(
|
|
104
|
+
relativeDir.split('/').join(path.sep),
|
|
105
|
+
path.join(root, ...relativeDir.split('/'))
|
|
106
|
+
);
|
|
107
|
+
cache.set(relativeDir, excluded);
|
|
108
|
+
return excluded;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const isExcluded = (relativePath) => {
|
|
112
|
+
const posix = relativePath.split(path.sep).join('/');
|
|
113
|
+
// Denied by the export policy: left out like an ignored file, and never named
|
|
114
|
+
if (scanner.isDenied(posix)) return true;
|
|
115
|
+
const parent = posix.includes('/') ? posix.slice(0, posix.lastIndexOf('/')) : '';
|
|
116
|
+
if (parent !== '' && excludedDirectory(parent)) return true;
|
|
117
|
+
return scanner.shouldIgnore(
|
|
118
|
+
posix.split('/').join(path.sep),
|
|
119
|
+
path.join(root, ...posix.split('/'))
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
isExcluded.isDenied = (relativePath) => scanner.isDenied(relativePath.split(path.sep).join('/'));
|
|
123
|
+
return isExcluded;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether a file holds binary data (a NUL byte in its first 8000 bytes, git's heuristic)
|
|
128
|
+
* @param {Buffer} buffer
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
export function isBinaryBuffer(buffer) {
|
|
132
|
+
return buffer.subarray(0, 8000).includes(0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether ctxman may print this file's content: the scanner's text-file rule, which
|
|
137
|
+
* also keeps secret-bearing files such as .env or *.pem out of exports
|
|
138
|
+
* @param {string} relativePath
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function isExportableText(relativePath) {
|
|
142
|
+
return FileUtils.isText(relativePath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Test files by common conventions (test/ and spec/ folders, *.test.*, *_test.go,
|
|
147
|
+
* test_*.py, FooTest.java, ...)
|
|
148
|
+
* @param {string} relativePath - Forward-slash path
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
export function isTestFile(relativePath) {
|
|
152
|
+
const posix = relativePath.split(path.sep).join('/');
|
|
153
|
+
const base = posix.split('/').pop();
|
|
154
|
+
return (
|
|
155
|
+
/(^|\/)(test|tests|__tests__|spec|specs|testing)\//i.test(posix) ||
|
|
156
|
+
/[._-](test|spec)s?\.[^./]+$/i.test(base) ||
|
|
157
|
+
/^test_.+\.py$/i.test(base) ||
|
|
158
|
+
/(Test|Tests|Spec|IT)\.(java|kt|kts|cs|scala|swift|php|groovy)$/.test(base)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* git pathspecs that select the files isTestFile() accepts (a superset; results are
|
|
164
|
+
* filtered with isTestFile again)
|
|
165
|
+
*/
|
|
166
|
+
export const TEST_PATHSPECS = [
|
|
167
|
+
':(glob)**/test/**',
|
|
168
|
+
':(glob)**/tests/**',
|
|
169
|
+
':(glob)**/__tests__/**',
|
|
170
|
+
':(glob)**/spec/**',
|
|
171
|
+
':(glob)**/specs/**',
|
|
172
|
+
':(glob)**/testing/**',
|
|
173
|
+
':(glob)**/*.test.*',
|
|
174
|
+
':(glob)**/*.spec.*',
|
|
175
|
+
':(glob)**/*_test.*',
|
|
176
|
+
':(glob)**/*-test.*',
|
|
177
|
+
':(glob)**/*.tests.*',
|
|
178
|
+
':(glob)**/test_*',
|
|
179
|
+
':(glob)**/*Test.*',
|
|
180
|
+
':(glob)**/*Tests.*',
|
|
181
|
+
':(glob)**/*Spec.*',
|
|
182
|
+
':(glob)**/*IT.*',
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Kind of prose file: 'adr' (architecture decision records), 'changelog', 'doc', or
|
|
187
|
+
* null for anything else
|
|
188
|
+
* @param {string} relativePath
|
|
189
|
+
* @returns {'adr'|'changelog'|'doc'|null}
|
|
190
|
+
*/
|
|
191
|
+
export function classifyTextFile(relativePath) {
|
|
192
|
+
const posix = relativePath.split(path.sep).join('/');
|
|
193
|
+
const base = posix.split('/').pop();
|
|
194
|
+
const ext = path.extname(base).toLowerCase();
|
|
195
|
+
const prose = DOC_EXTENSIONS.has(ext);
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
/(^|\/)(adr|adrs|decisions|architecture[-_]decisions?)\//i.test(posix) ||
|
|
199
|
+
/(^|[^a-z])adr([^a-z]|$)/i.test(base)
|
|
200
|
+
) {
|
|
201
|
+
return prose || ext === '' ? 'adr' : null;
|
|
202
|
+
}
|
|
203
|
+
if (/^(changelog|changes|history|news|releases?)(\.|$)/i.test(base)) {
|
|
204
|
+
return 'changelog';
|
|
205
|
+
}
|
|
206
|
+
return prose ? 'doc' : null;
|
|
207
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* References mined from commit messages: issue and pull request numbers, ticket keys,
|
|
3
|
+
* CVE ids, URLs and Co-authored-by / Reviewed-by trailers, with web links when `origin`
|
|
4
|
+
* is a GitHub, GitLab or Bitbucket remote
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Upper-case words followed by a number that are not ticket keys
|
|
8
|
+
const NOT_TICKETS = new Set([
|
|
9
|
+
'AES',
|
|
10
|
+
'ANSI',
|
|
11
|
+
'ASCII',
|
|
12
|
+
'BASE',
|
|
13
|
+
'BSD',
|
|
14
|
+
'CC',
|
|
15
|
+
'CP',
|
|
16
|
+
'CRC',
|
|
17
|
+
'CVE',
|
|
18
|
+
'CWE',
|
|
19
|
+
'DES',
|
|
20
|
+
'ECMA',
|
|
21
|
+
'ES',
|
|
22
|
+
'GH',
|
|
23
|
+
'GPL',
|
|
24
|
+
'HTTP',
|
|
25
|
+
'HTTPS',
|
|
26
|
+
'IEC',
|
|
27
|
+
'IEEE',
|
|
28
|
+
'IPV',
|
|
29
|
+
'ISO',
|
|
30
|
+
'LGPL',
|
|
31
|
+
'AGPL',
|
|
32
|
+
'MD',
|
|
33
|
+
'MPL',
|
|
34
|
+
'PEP',
|
|
35
|
+
'POSIX',
|
|
36
|
+
'RFC',
|
|
37
|
+
'RSA',
|
|
38
|
+
'SHA',
|
|
39
|
+
'SSL',
|
|
40
|
+
'TLS',
|
|
41
|
+
'UCS',
|
|
42
|
+
'UTF',
|
|
43
|
+
'WCAG',
|
|
44
|
+
'WIN',
|
|
45
|
+
'X',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const KIND_RANK = { pr: 2, mr: 2, issue: 1 };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a remote URL (https, ssh, scp-like `git@host:owner/repo.git`) into a provider and
|
|
52
|
+
* a web base URL. Credentials and ports of ssh remotes are dropped
|
|
53
|
+
* @param {string|null} url
|
|
54
|
+
* @returns {{provider: 'github'|'gitlab'|'bitbucket'|null, host: string, repoPath: string,
|
|
55
|
+
* webUrl: string}|null} null for local paths and unparsable URLs
|
|
56
|
+
*/
|
|
57
|
+
export function parseRemote(url) {
|
|
58
|
+
if (!url || typeof url !== 'string') return null;
|
|
59
|
+
const trimmed = url.trim();
|
|
60
|
+
let host;
|
|
61
|
+
let port = '';
|
|
62
|
+
let repoPath;
|
|
63
|
+
|
|
64
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = new URL(trimmed);
|
|
68
|
+
} catch (_error) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (parsed.protocol === 'file:') return null;
|
|
72
|
+
host = parsed.hostname;
|
|
73
|
+
if (/^https?:$/.test(parsed.protocol) && parsed.port) port = `:${parsed.port}`;
|
|
74
|
+
repoPath = decodeURIComponent(parsed.pathname);
|
|
75
|
+
} else {
|
|
76
|
+
// scp-like syntax; a single letter before ':' is a Windows drive
|
|
77
|
+
const scp = trimmed.match(/^(?:[^@/\s]+@)?([^:/\s]{2,}):(?!\/\/)(.+)$/);
|
|
78
|
+
if (!scp) return null;
|
|
79
|
+
host = scp[1];
|
|
80
|
+
repoPath = scp[2];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
repoPath = repoPath.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
|
|
84
|
+
if (!host || !repoPath.includes('/')) return null;
|
|
85
|
+
|
|
86
|
+
const provider = /github/i.test(host)
|
|
87
|
+
? 'github'
|
|
88
|
+
: /gitlab/i.test(host)
|
|
89
|
+
? 'gitlab'
|
|
90
|
+
: /bitbucket/i.test(host)
|
|
91
|
+
? 'bitbucket'
|
|
92
|
+
: null;
|
|
93
|
+
if (provider === 'github' || provider === 'bitbucket') {
|
|
94
|
+
repoPath = repoPath.split('/').slice(0, 2).join('/');
|
|
95
|
+
}
|
|
96
|
+
// ssh.github.com / altssh.gitlab.com serve ssh only
|
|
97
|
+
const webHost = host.replace(/^(alt)?ssh\./i, '').toLowerCase();
|
|
98
|
+
return { provider, host: webHost, repoPath, webUrl: `https://${webHost}${port}/${repoPath}` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Web link for an issue / pull request / merge request
|
|
103
|
+
* @param {ReturnType<typeof parseRemote>} remote
|
|
104
|
+
* @param {string|null} repoPath - Another repository on the same host (owner/repo#12)
|
|
105
|
+
* @param {number} number
|
|
106
|
+
* @param {string} kind
|
|
107
|
+
* @returns {string|null}
|
|
108
|
+
*/
|
|
109
|
+
export function issueLink(remote, repoPath, number, kind) {
|
|
110
|
+
if (!remote?.provider) return null;
|
|
111
|
+
const base = repoPath
|
|
112
|
+
? remote.webUrl.slice(0, remote.webUrl.length - remote.repoPath.length) + repoPath
|
|
113
|
+
: remote.webUrl;
|
|
114
|
+
const isChange = kind === 'pr' || kind === 'mr';
|
|
115
|
+
switch (remote.provider) {
|
|
116
|
+
case 'github':
|
|
117
|
+
// GitHub redirects /issues/N to /pull/N for pull requests
|
|
118
|
+
return `${base}/${isChange ? 'pull' : 'issues'}/${number}`;
|
|
119
|
+
case 'gitlab':
|
|
120
|
+
return `${base}/-/${isChange ? 'merge_requests' : 'issues'}/${number}`;
|
|
121
|
+
case 'bitbucket':
|
|
122
|
+
return `${base}/${isChange ? 'pull-requests' : 'issues'}/${number}`;
|
|
123
|
+
default:
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remove user:password@ from a URL
|
|
130
|
+
* @param {string} url
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
function stripCredentials(url) {
|
|
134
|
+
return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/i, '$1');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Collect references from commits (newest first). Each entry lists the short hashes of
|
|
139
|
+
* the commits that mention it, in history order
|
|
140
|
+
* @param {Array<{shortSha: string, subject: string, body: string}>} commits
|
|
141
|
+
* @param {ReturnType<typeof parseRemote>} remote
|
|
142
|
+
* @returns {{issues: object[], tickets: object[], cves: object[], urls: object[],
|
|
143
|
+
* coAuthors: object[], reviewers: object[]}}
|
|
144
|
+
*/
|
|
145
|
+
export function mineReferences(commits, remote) {
|
|
146
|
+
const issues = new Map();
|
|
147
|
+
const tickets = new Map();
|
|
148
|
+
const cves = new Map();
|
|
149
|
+
const urls = new Map();
|
|
150
|
+
const coAuthors = new Map();
|
|
151
|
+
const reviewers = new Map();
|
|
152
|
+
|
|
153
|
+
const note = (map, key, entry, sha) => {
|
|
154
|
+
const existing = map.get(key);
|
|
155
|
+
if (!existing) {
|
|
156
|
+
map.set(key, { ...entry, commits: [sha] });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!existing.commits.includes(sha)) existing.commits.push(sha);
|
|
160
|
+
if (entry.closes) existing.closes = true;
|
|
161
|
+
if (entry.kind && (KIND_RANK[entry.kind] ?? 0) > (KIND_RANK[existing.kind] ?? 0)) {
|
|
162
|
+
existing.kind = entry.kind;
|
|
163
|
+
existing.url = entry.url;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// kind: issue, pr (pull request) or mr (GitLab merge request); closes: a closing
|
|
168
|
+
// keyword (fixes / closes / resolves) referred to it
|
|
169
|
+
const addIssue = (repoPath, number, kind, sha, closes = false) => {
|
|
170
|
+
if (!/^[1-9]\d{0,8}$/.test(number)) return;
|
|
171
|
+
const id = `${repoPath ?? ''}#${number}`;
|
|
172
|
+
note(
|
|
173
|
+
issues,
|
|
174
|
+
id,
|
|
175
|
+
{
|
|
176
|
+
id,
|
|
177
|
+
number: Number(number),
|
|
178
|
+
repo: repoPath ?? null,
|
|
179
|
+
kind,
|
|
180
|
+
closes,
|
|
181
|
+
url: issueLink(remote, repoPath, number, kind),
|
|
182
|
+
},
|
|
183
|
+
sha
|
|
184
|
+
);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
for (const commit of commits) {
|
|
188
|
+
const sha = commit.shortSha;
|
|
189
|
+
const message = `${commit.subject}\n\n${commit.body}`;
|
|
190
|
+
|
|
191
|
+
// URLs first, then blank them out so their fragments and paths are not read as refs
|
|
192
|
+
let text = message.replace(/\bhttps?:\/\/[^\s<>"'`]+/gi, (raw) => {
|
|
193
|
+
const url = stripCredentials(raw.replace(/[).,;:!?\]}>]+$/, ''));
|
|
194
|
+
note(urls, url, { url }, sha);
|
|
195
|
+
return ' '.repeat(raw.length);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
for (const match of text.matchAll(/^\s*(Co-authored-by|Reviewed-by)\s*:\s*(.+?)\s*$/gim)) {
|
|
199
|
+
const value = match[2];
|
|
200
|
+
const map = match[1].toLowerCase() === 'co-authored-by' ? coAuthors : reviewers;
|
|
201
|
+
note(map, value.toLowerCase(), { value }, sha);
|
|
202
|
+
}
|
|
203
|
+
// Trailers carry e-mail addresses, not references
|
|
204
|
+
text = text.replace(/^\s*[\w-]+-by\s*:.*$/gim, '');
|
|
205
|
+
|
|
206
|
+
// Pull / merge requests: squash suffix "(#45)", merge commits, GitLab "!45"
|
|
207
|
+
const squash = commit.subject.match(/\(#(\d+)\)\s*$/);
|
|
208
|
+
if (squash) addIssue(null, squash[1], remote?.provider === 'gitlab' ? 'issue' : 'pr', sha);
|
|
209
|
+
for (const match of text.matchAll(/\b(?:Merge pull request|pull request) #(\d+)/gi)) {
|
|
210
|
+
addIssue(null, match[1], 'pr', sha);
|
|
211
|
+
}
|
|
212
|
+
if (remote?.provider === 'gitlab') {
|
|
213
|
+
for (const match of text.matchAll(/(?:^|[\s(])(?:([\w.-]+(?:\/[\w.-]+)+))?!(\d+)\b/g)) {
|
|
214
|
+
addIssue(match[1] ?? null, match[2], 'mr', sha);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const match of text.matchAll(
|
|
219
|
+
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+(?:([\w.-]+\/[\w.-]+))?#(\d+)\b/gi
|
|
220
|
+
)) {
|
|
221
|
+
addIssue(match[1] ?? null, match[2], 'issue', sha, true);
|
|
222
|
+
}
|
|
223
|
+
for (const match of text.matchAll(/(?:^|[^\w&/#])(?:([\w.-]+\/[\w.-]+))?#(\d+)(?!\w)/g)) {
|
|
224
|
+
addIssue(match[1] ?? null, match[2], 'issue', sha);
|
|
225
|
+
}
|
|
226
|
+
for (const match of text.matchAll(/\bGH-(\d+)\b/g)) {
|
|
227
|
+
addIssue(null, match[1], 'issue', sha);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const match of text.matchAll(/\bCVE-\d{4}-\d{4,}\b/g)) {
|
|
231
|
+
note(cves, match[0], { id: match[0] }, sha);
|
|
232
|
+
}
|
|
233
|
+
for (const match of text.matchAll(/(?<![\w-])([A-Z][A-Z0-9]{1,9})-(\d{1,7})(?![\w])/g)) {
|
|
234
|
+
if (NOT_TICKETS.has(match[1]) || /^0/.test(match[2])) continue;
|
|
235
|
+
note(tickets, match[0], { id: match[0] }, sha);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
issues: [...issues.values()],
|
|
241
|
+
tickets: [...tickets.values()],
|
|
242
|
+
cves: [...cves.values()],
|
|
243
|
+
urls: [...urls.values()],
|
|
244
|
+
coAuthors: [...coAuthors.values()],
|
|
245
|
+
reviewers: [...reviewers.values()],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Ids of the references a single commit mentions (for the history section)
|
|
251
|
+
* @param {ReturnType<typeof mineReferences>} references
|
|
252
|
+
* @param {string} shortSha
|
|
253
|
+
* @returns {string[]}
|
|
254
|
+
*/
|
|
255
|
+
export function referencesOf(references, shortSha) {
|
|
256
|
+
return [...references.issues, ...references.tickets, ...references.cves]
|
|
257
|
+
.filter((entry) => entry.commits.includes(shortSha))
|
|
258
|
+
.map((entry) => entry.id);
|
|
259
|
+
}
|