@cspeach/cli 0.6.0
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/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- package/package.json +83 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git state extractor.
|
|
3
|
+
*
|
|
4
|
+
* Surfaces just enough VCS context for the agent to know "what branch
|
|
5
|
+
* are we on, is the working copy dirty, what just shipped." Shells out
|
|
6
|
+
* to the git CLI rather than pulling in a Git library — git is already
|
|
7
|
+
* available wherever a developer runs CSPeach.
|
|
8
|
+
*
|
|
9
|
+
* Discipline:
|
|
10
|
+
*
|
|
11
|
+
* - Best-effort. If git isn't installed, the directory isn't a repo,
|
|
12
|
+
* or any subprocess errors out, we return `isRepo: false` and the
|
|
13
|
+
* rest of the fields zero/null. We never throw.
|
|
14
|
+
*
|
|
15
|
+
* - Each subprocess has a short timeout (3s). Hung git processes (e.g.
|
|
16
|
+
* waiting on a stuck lock) cannot block project-context computation.
|
|
17
|
+
*
|
|
18
|
+
* - We do not run any commands that mutate state. Only `git rev-parse`,
|
|
19
|
+
* `git status --porcelain`, `git log`. Read-only by construction.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
const GIT_TIMEOUT_MS = 3000;
|
|
23
|
+
const EMPTY_STATE = {
|
|
24
|
+
isRepo: false,
|
|
25
|
+
branch: null,
|
|
26
|
+
headSha: null,
|
|
27
|
+
dirtyFileCount: 0,
|
|
28
|
+
recentCommits: [],
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Inspect the git state at `cwd`. Returns sane defaults on every error
|
|
32
|
+
* (no git, no repo, subprocess timeout, etc.) — never throws.
|
|
33
|
+
*/
|
|
34
|
+
export async function getGitState(cwd) {
|
|
35
|
+
// Check repo membership first; bails early on non-repos so we don't
|
|
36
|
+
// shell out four times unnecessarily.
|
|
37
|
+
const insideTree = await runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
|
|
38
|
+
if (!insideTree.ok || insideTree.stdout.trim() !== 'true') {
|
|
39
|
+
return EMPTY_STATE;
|
|
40
|
+
}
|
|
41
|
+
const [branchResult, shaResult, statusResult, logResult] = await Promise.all([
|
|
42
|
+
runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
43
|
+
runGit(cwd, ['rev-parse', 'HEAD']),
|
|
44
|
+
runGit(cwd, ['status', '--porcelain']),
|
|
45
|
+
runGit(cwd, ['log', '-5', '--pretty=%h %s']),
|
|
46
|
+
]);
|
|
47
|
+
const branch = branchResult.ok ? branchResult.stdout.trim() || null : null;
|
|
48
|
+
const headSha = shaResult.ok ? shaResult.stdout.trim() || null : null;
|
|
49
|
+
const dirtyFileCount = statusResult.ok
|
|
50
|
+
? statusResult.stdout
|
|
51
|
+
.split('\n')
|
|
52
|
+
.filter((line) => line.trim().length > 0).length
|
|
53
|
+
: 0;
|
|
54
|
+
const recentCommits = logResult.ok
|
|
55
|
+
? logResult.stdout
|
|
56
|
+
.split('\n')
|
|
57
|
+
.map((line) => line.trim())
|
|
58
|
+
.filter((line) => line.length > 0)
|
|
59
|
+
: [];
|
|
60
|
+
return {
|
|
61
|
+
isRepo: true,
|
|
62
|
+
branch,
|
|
63
|
+
headSha,
|
|
64
|
+
dirtyFileCount,
|
|
65
|
+
recentCommits,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Run a single git subprocess with a hard timeout. Resolves with
|
|
70
|
+
* `{ ok: false, stdout: '' }` on any failure (non-zero exit, ENOENT,
|
|
71
|
+
* timeout). Never rejects.
|
|
72
|
+
*/
|
|
73
|
+
function runGit(cwd, args) {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
let resolved = false;
|
|
76
|
+
let stdout = '';
|
|
77
|
+
let child;
|
|
78
|
+
const finish = (ok) => {
|
|
79
|
+
if (resolved)
|
|
80
|
+
return;
|
|
81
|
+
resolved = true;
|
|
82
|
+
try {
|
|
83
|
+
child?.kill();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// ignore
|
|
87
|
+
}
|
|
88
|
+
resolve({ ok, stdout });
|
|
89
|
+
};
|
|
90
|
+
const timer = setTimeout(() => finish(false), GIT_TIMEOUT_MS);
|
|
91
|
+
try {
|
|
92
|
+
child = spawn('git', args, {
|
|
93
|
+
cwd,
|
|
94
|
+
windowsHide: true,
|
|
95
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
finish(false);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
child.stdout?.on('data', (chunk) => {
|
|
104
|
+
stdout += chunk.toString('utf-8');
|
|
105
|
+
});
|
|
106
|
+
child.on('error', () => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
finish(false);
|
|
109
|
+
});
|
|
110
|
+
child.on('close', (code) => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
finish(code === 0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project file index walker.
|
|
3
|
+
*
|
|
4
|
+
* Walks the project tree and produces a FileIndex — counts + paths +
|
|
5
|
+
* top-level dirs — without ever reading file contents. The model gets
|
|
6
|
+
* a structural view of what exists; if it needs content, it uses
|
|
7
|
+
* `file_read` (Phase 3 tool) on demand.
|
|
8
|
+
*
|
|
9
|
+
* Discipline:
|
|
10
|
+
*
|
|
11
|
+
* - Hard cap at 10,000 files traversed. Beyond that we mark the
|
|
12
|
+
* project as `truncated` and surface that to the model. Larger
|
|
13
|
+
* projects rely on on-demand glob/grep search rather than the
|
|
14
|
+
* static index.
|
|
15
|
+
*
|
|
16
|
+
* - At most 500 paths shipped in `FileIndex.paths`. Selected by
|
|
17
|
+
* relevance — source files first, sorted by modification time
|
|
18
|
+
* (most recent first) so the model sees what the user has been
|
|
19
|
+
* working on.
|
|
20
|
+
*
|
|
21
|
+
* - Honors .gitignore + .cspeachignore via a minimal in-tree parser.
|
|
22
|
+
* No external dependencies. The parser handles the common cases —
|
|
23
|
+
* name patterns, *.ext patterns, directory patterns. Edge-case
|
|
24
|
+
* .gitignore wildcards (e.g. ! negation, glob stars) are not fully
|
|
25
|
+
* supported; users with exotic ignore rules can lean on
|
|
26
|
+
* .cspeachignore for project-context-specific exclusions.
|
|
27
|
+
*
|
|
28
|
+
* - Always-skipped directories: node_modules, .git, dist, build,
|
|
29
|
+
* .cspeach, .abapforge, .next, .turbo, target. The first wave of
|
|
30
|
+
* walks before any .gitignore parse stage uses this safety net.
|
|
31
|
+
*/
|
|
32
|
+
import { promises as fs } from 'node:fs';
|
|
33
|
+
import * as path from 'node:path';
|
|
34
|
+
/** Hard cap on files traversed. Beyond this the walk stops + truncated=true. */
|
|
35
|
+
const MAX_FILES_TRAVERSED = 10_000;
|
|
36
|
+
/** Cap on paths shipped in FileIndex. Selected by relevance. */
|
|
37
|
+
const MAX_PATHS_IN_INDEX = 500;
|
|
38
|
+
const ALWAYS_SKIPPED_DIRS = new Set([
|
|
39
|
+
'node_modules',
|
|
40
|
+
'.git',
|
|
41
|
+
'dist',
|
|
42
|
+
'build',
|
|
43
|
+
'.cspeach',
|
|
44
|
+
'.abapforge',
|
|
45
|
+
'.next',
|
|
46
|
+
'.turbo',
|
|
47
|
+
'target',
|
|
48
|
+
'.vscode',
|
|
49
|
+
'.idea',
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* Source-shaped extensions, prioritized when selecting the top 500
|
|
53
|
+
* paths shipped in the index. The model cares about these more than
|
|
54
|
+
* generated assets / lock files.
|
|
55
|
+
*/
|
|
56
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
57
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
58
|
+
'.abap',
|
|
59
|
+
'.cds', '.ddls', '.ddlx', '.dcls', '.bdef', '.srvd', '.srvb',
|
|
60
|
+
'.xml',
|
|
61
|
+
'.yaml', '.yml',
|
|
62
|
+
'.json',
|
|
63
|
+
'.md',
|
|
64
|
+
'.properties',
|
|
65
|
+
'.py', '.go', '.rs', '.java',
|
|
66
|
+
'.css', '.scss',
|
|
67
|
+
'.html',
|
|
68
|
+
]);
|
|
69
|
+
/**
|
|
70
|
+
* Walk the project rooted at `cwd` and produce a FileIndex. Best-effort:
|
|
71
|
+
* IO errors during the walk are silently skipped (the offending dir/file
|
|
72
|
+
* just doesn't contribute to the count). Never throws.
|
|
73
|
+
*/
|
|
74
|
+
export async function indexFiles(cwd) {
|
|
75
|
+
const ignoreMatchers = await loadIgnoreMatchers(cwd);
|
|
76
|
+
/** Files collected with their stat info — used to rank for the path list. */
|
|
77
|
+
const collected = [];
|
|
78
|
+
const byExtension = {};
|
|
79
|
+
let totalFiles = 0;
|
|
80
|
+
let truncated = false;
|
|
81
|
+
async function walk(dir, relPrefix) {
|
|
82
|
+
if (truncated)
|
|
83
|
+
return;
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const ent of entries) {
|
|
92
|
+
if (truncated)
|
|
93
|
+
return;
|
|
94
|
+
const relPath = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
|
|
95
|
+
if (ent.isDirectory()) {
|
|
96
|
+
if (ALWAYS_SKIPPED_DIRS.has(ent.name))
|
|
97
|
+
continue;
|
|
98
|
+
if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ true))
|
|
99
|
+
continue;
|
|
100
|
+
await walk(path.join(dir, ent.name), relPath);
|
|
101
|
+
}
|
|
102
|
+
else if (ent.isFile()) {
|
|
103
|
+
if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ false))
|
|
104
|
+
continue;
|
|
105
|
+
if (totalFiles >= MAX_FILES_TRAVERSED) {
|
|
106
|
+
truncated = true;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
totalFiles++;
|
|
110
|
+
const ext = extOf(ent.name);
|
|
111
|
+
byExtension[ext] = (byExtension[ext] ?? 0) + 1;
|
|
112
|
+
// Read mtime cheaply; on error use 0 so the file sorts late.
|
|
113
|
+
let mtime = 0;
|
|
114
|
+
try {
|
|
115
|
+
const s = await fs.stat(path.join(dir, ent.name));
|
|
116
|
+
mtime = s.mtimeMs;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// ignore
|
|
120
|
+
}
|
|
121
|
+
collected.push({ path: relPath, ext, mtime });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await walk(cwd, '');
|
|
126
|
+
// Top-level dirs that survived the walk (i.e. weren't skipped).
|
|
127
|
+
let topLevelDirs = [];
|
|
128
|
+
try {
|
|
129
|
+
const topLevel = await fs.readdir(cwd, { withFileTypes: true });
|
|
130
|
+
topLevelDirs = topLevel
|
|
131
|
+
.filter((e) => e.isDirectory() && !ALWAYS_SKIPPED_DIRS.has(e.name) && !matchesIgnore(ignoreMatchers, e.name, true))
|
|
132
|
+
.map((e) => e.name)
|
|
133
|
+
.sort();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// leave empty
|
|
137
|
+
}
|
|
138
|
+
// Rank collected files: source files first, then by mtime descending.
|
|
139
|
+
collected.sort((a, b) => {
|
|
140
|
+
const aSource = SOURCE_EXTENSIONS.has(a.ext) ? 1 : 0;
|
|
141
|
+
const bSource = SOURCE_EXTENSIONS.has(b.ext) ? 1 : 0;
|
|
142
|
+
if (aSource !== bSource)
|
|
143
|
+
return bSource - aSource;
|
|
144
|
+
return b.mtime - a.mtime;
|
|
145
|
+
});
|
|
146
|
+
const paths = collected.slice(0, MAX_PATHS_IN_INDEX).map((f) => f.path);
|
|
147
|
+
return {
|
|
148
|
+
totalFiles,
|
|
149
|
+
byExtension,
|
|
150
|
+
topLevelDirs,
|
|
151
|
+
paths,
|
|
152
|
+
truncated,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
async function loadIgnoreMatchers(cwd) {
|
|
156
|
+
const patterns = [];
|
|
157
|
+
for (const filename of ['.gitignore', '.cspeachignore']) {
|
|
158
|
+
const raw = await readFileSafe(path.join(cwd, filename));
|
|
159
|
+
if (raw === null)
|
|
160
|
+
continue;
|
|
161
|
+
for (const rawLine of raw.split('\n')) {
|
|
162
|
+
const pat = parseIgnoreLine(rawLine);
|
|
163
|
+
if (pat)
|
|
164
|
+
patterns.push(pat);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { patterns };
|
|
168
|
+
}
|
|
169
|
+
function parseIgnoreLine(rawLine) {
|
|
170
|
+
const line = rawLine.replace(/^\s+|\s+$/g, '');
|
|
171
|
+
if (line.length === 0)
|
|
172
|
+
return null;
|
|
173
|
+
if (line.startsWith('#'))
|
|
174
|
+
return null;
|
|
175
|
+
// We don't support negation (!), root-anchored paths (/), or **.
|
|
176
|
+
// Anything more exotic than basename-or-glob is silently ignored —
|
|
177
|
+
// users who need that can rely on the ALWAYS_SKIPPED_DIRS hardcode
|
|
178
|
+
// or contribute back if needed.
|
|
179
|
+
if (line.startsWith('!'))
|
|
180
|
+
return null;
|
|
181
|
+
if (line.includes('/')) {
|
|
182
|
+
// Path-rooted patterns are partially handled — we treat "build/" or
|
|
183
|
+
// "logs" as a basename match. "a/b/c" is too specific for our v1.
|
|
184
|
+
if (!line.endsWith('/'))
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const dirOnly = line.endsWith('/');
|
|
188
|
+
const pattern = dirOnly ? line.slice(0, -1) : line;
|
|
189
|
+
if (pattern.length === 0)
|
|
190
|
+
return null;
|
|
191
|
+
if (pattern.includes('*')) {
|
|
192
|
+
// Convert simple "*.ext" or "foo*" pattern to a regex.
|
|
193
|
+
// We only handle * (no ? no [..]).
|
|
194
|
+
const re = new RegExp('^' + pattern.split('*').map(escapeRegex).join('.*') + '$');
|
|
195
|
+
return {
|
|
196
|
+
source: rawLine,
|
|
197
|
+
dirOnly,
|
|
198
|
+
matches: (basename) => re.test(basename),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// Literal basename match.
|
|
202
|
+
return {
|
|
203
|
+
source: rawLine,
|
|
204
|
+
dirOnly,
|
|
205
|
+
matches: (basename) => basename === pattern,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function matchesIgnore(m, relPath, isDirectory) {
|
|
209
|
+
const basename = relPath.split('/').pop() ?? relPath;
|
|
210
|
+
for (const p of m.patterns) {
|
|
211
|
+
if (p.dirOnly && !isDirectory)
|
|
212
|
+
continue;
|
|
213
|
+
if (p.matches(basename))
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
function escapeRegex(s) {
|
|
219
|
+
return s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
220
|
+
}
|
|
221
|
+
/* ── Small helpers ─────────────────────────────────────────────────── */
|
|
222
|
+
function extOf(filename) {
|
|
223
|
+
const idx = filename.lastIndexOf('.');
|
|
224
|
+
if (idx <= 0)
|
|
225
|
+
return '';
|
|
226
|
+
return filename.slice(idx).toLowerCase();
|
|
227
|
+
}
|
|
228
|
+
async function readFileSafe(p) {
|
|
229
|
+
try {
|
|
230
|
+
return await fs.readFile(p, 'utf-8');
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project context module — public API.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 of Track B. As of Phase 1F this module is fully implemented;
|
|
5
|
+
* NOTHING IN THE EXISTING CLI IMPORTS IT YET. Phase 2 wires it into
|
|
6
|
+
* the agent's system prompt behind the CSPEACH_PROJECT_CONTEXT feature
|
|
7
|
+
* flag. Until then, this module is fully isolated — risk to existing
|
|
8
|
+
* CSPeach is zero by construction.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
*
|
|
12
|
+
* import { getProjectContext, renderProjectContext } from './project-context/index.js';
|
|
13
|
+
*
|
|
14
|
+
* const ctx = await getProjectContext(process.cwd());
|
|
15
|
+
* if (ctx) {
|
|
16
|
+
* const block = renderProjectContext(ctx);
|
|
17
|
+
* // splice `block` into the system prompt
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Returns null on any error or when nothing project-shaped is detected.
|
|
21
|
+
* Never throws — error handling is internal. See ./detect.ts for the
|
|
22
|
+
* detection priority order and ./types.ts for the structured shape.
|
|
23
|
+
*/
|
|
24
|
+
import { readConventions } from './conventions.js';
|
|
25
|
+
import { detectProjectType } from './detect.js';
|
|
26
|
+
import { extractAbapCloud } from './domain/abap-cloud.js';
|
|
27
|
+
import { extractAbapGit } from './domain/abapgit.js';
|
|
28
|
+
import { extractCap } from './domain/cap.js';
|
|
29
|
+
import { extractFiori } from './domain/fiori.js';
|
|
30
|
+
import { getGitState } from './git.js';
|
|
31
|
+
import { indexFiles } from './index-files.js';
|
|
32
|
+
export { renderProjectContext } from './render.js';
|
|
33
|
+
/**
|
|
34
|
+
* Compute the full project context for a directory. Best-effort: returns
|
|
35
|
+
* null on hard failure (e.g. cwd doesn't exist), otherwise an envelope
|
|
36
|
+
* with whatever was detectable. Even projects of type 'unknown' return
|
|
37
|
+
* a non-null ProjectContext so callers can surface the SAP-only case
|
|
38
|
+
* (live ABAP work with no on-disk project).
|
|
39
|
+
*
|
|
40
|
+
* Phase 1F implementation. Phase 2 will wrap this in a feature-flag
|
|
41
|
+
* check at the agent loop's prompt assembly site.
|
|
42
|
+
*/
|
|
43
|
+
export async function getProjectContext(cwd, options = {}) {
|
|
44
|
+
// Detection runs first — if cwd is bogus we bail completely.
|
|
45
|
+
const detected = await detectProjectType(cwd);
|
|
46
|
+
if (detected === null) {
|
|
47
|
+
// Even with no on-disk project, a SAP connection alone is enough
|
|
48
|
+
// to return SOMETHING. Otherwise null.
|
|
49
|
+
if (options.sapConnection) {
|
|
50
|
+
return assembleSapOnlyContext(cwd, options.sapConnection);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
// Run independent extractors in parallel — they share no state.
|
|
55
|
+
const [conventions, index, git, domainObjects] = await Promise.all([
|
|
56
|
+
readConventions(cwd),
|
|
57
|
+
indexFiles(cwd),
|
|
58
|
+
getGitState(cwd),
|
|
59
|
+
extractDomain(cwd, detected),
|
|
60
|
+
]);
|
|
61
|
+
return {
|
|
62
|
+
type: detected,
|
|
63
|
+
root: cwd,
|
|
64
|
+
conventions,
|
|
65
|
+
index,
|
|
66
|
+
entryPoints: [], // Phase 2/3 will populate per-type entry-point lists
|
|
67
|
+
domainObjects,
|
|
68
|
+
git,
|
|
69
|
+
sapConnection: options.sapConnection ?? null,
|
|
70
|
+
detectedAt: new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/* ── Internal helpers ──────────────────────────────────────────────── */
|
|
74
|
+
/**
|
|
75
|
+
* Run the right domain extractor for the detected type. `mixed` is
|
|
76
|
+
* type-stubbed for now — recursive per-module assembly is a follow-up
|
|
77
|
+
* (the technical plan flags it as Phase 1F's "if you encounter it,
|
|
78
|
+
* type-stub it; assembly handles the recursion later" note).
|
|
79
|
+
*/
|
|
80
|
+
async function extractDomain(cwd, type) {
|
|
81
|
+
switch (type) {
|
|
82
|
+
case 'fiori-ui5':
|
|
83
|
+
return extractFiori(cwd);
|
|
84
|
+
case 'cap-node':
|
|
85
|
+
return extractCap(cwd);
|
|
86
|
+
case 'abapgit':
|
|
87
|
+
return extractAbapGit(cwd);
|
|
88
|
+
case 'abap-cloud':
|
|
89
|
+
return extractAbapCloud(cwd);
|
|
90
|
+
case 'mixed':
|
|
91
|
+
// Stub. Assembly of per-module sub-contexts is Phase 1F+.
|
|
92
|
+
return { type: 'mixed', modules: [] };
|
|
93
|
+
case 'unknown':
|
|
94
|
+
default:
|
|
95
|
+
return { type: 'unknown' };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Build a minimal ProjectContext for the SAP-only case (caller passed
|
|
100
|
+
* a sapConnection but cwd doesn't resolve to any project shape). This
|
|
101
|
+
* is the most common CSPeach use case today: a consultant connected
|
|
102
|
+
* to live SAP via ADT with no on-disk project at all.
|
|
103
|
+
*/
|
|
104
|
+
async function assembleSapOnlyContext(cwd, sapConnection) {
|
|
105
|
+
const conventions = await readConventions(cwd);
|
|
106
|
+
return {
|
|
107
|
+
type: 'unknown',
|
|
108
|
+
root: cwd,
|
|
109
|
+
conventions,
|
|
110
|
+
index: { totalFiles: 0, byExtension: {}, topLevelDirs: [], paths: [], truncated: false },
|
|
111
|
+
entryPoints: [],
|
|
112
|
+
domainObjects: { type: 'unknown' },
|
|
113
|
+
git: { isRepo: false, branch: null, headSha: null, dirtyFileCount: 0, recentCommits: [] },
|
|
114
|
+
sapConnection,
|
|
115
|
+
detectedAt: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
}
|