@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,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* abapGit domain extractor.
|
|
3
|
+
*
|
|
4
|
+
* abapGit serializes ABAP repository objects to disk under `src/`, with
|
|
5
|
+
* each object as one or more files following the pattern
|
|
6
|
+
*
|
|
7
|
+
* <object_name>.<type>.<extension>
|
|
8
|
+
*
|
|
9
|
+
* Examples:
|
|
10
|
+
* zhello.prog.abap program ZHELLO
|
|
11
|
+
* zcl_foo.clas.abap class ZCL_FOO
|
|
12
|
+
* zif_bar.intf.abap interface ZIF_BAR
|
|
13
|
+
* ztab.tabl.xml table ZTAB
|
|
14
|
+
* ztype.dtel.xml data element ZTYPE
|
|
15
|
+
* zfg.fugr.abap function group ZFG
|
|
16
|
+
* zi_view.ddls.asddls CDS view ZI_VIEW
|
|
17
|
+
*
|
|
18
|
+
* The package an object belongs to is inferred from its folder. abapGit
|
|
19
|
+
* mirrors the SAP package hierarchy as nested folders under `src/`, so
|
|
20
|
+
* `src/zfi/zfi_payments/zcl_foo.clas.abap` belongs to package
|
|
21
|
+
* ZFI_PAYMENTS (the immediate parent folder name, uppercased).
|
|
22
|
+
*
|
|
23
|
+
* Discipline: filenames carry enough — we never open the files. The
|
|
24
|
+
* agent reads source on demand via Phase 3 file_read or sap_get_source.
|
|
25
|
+
*/
|
|
26
|
+
import { promises as fs } from 'node:fs';
|
|
27
|
+
import * as path from 'node:path';
|
|
28
|
+
/** Cap on tree entries walked. abapGit repos with > 5K objects are rare. */
|
|
29
|
+
const MAX_ENTRIES = 5000;
|
|
30
|
+
/**
|
|
31
|
+
* Parse an abapGit filename into name + type. Returns null when the
|
|
32
|
+
* filename doesn't follow the convention (skipped silently — many
|
|
33
|
+
* repos contain README.md, .gitignore, etc.).
|
|
34
|
+
*
|
|
35
|
+
* Matches:
|
|
36
|
+
* <name>.<type>.<ext> → { name: <name>.toUpperCase(), type: <type>.toUpperCase() }
|
|
37
|
+
* special: package files like `package.devc.xml` map to type DEVC.
|
|
38
|
+
*/
|
|
39
|
+
export function parseAbapFilename(filename) {
|
|
40
|
+
const lower = filename.toLowerCase();
|
|
41
|
+
// Two-dot pattern: name.type.ext
|
|
42
|
+
const parts = lower.split('.');
|
|
43
|
+
if (parts.length < 3)
|
|
44
|
+
return null;
|
|
45
|
+
// Anything before the type segment is the object name; type is the
|
|
46
|
+
// second-to-last segment. abapGit object names may legitimately contain
|
|
47
|
+
// dots in unusual cases (e.g. namespace prefixes), so we join all but
|
|
48
|
+
// the last two segments back into the name.
|
|
49
|
+
const ext = parts[parts.length - 1];
|
|
50
|
+
const type = parts[parts.length - 2];
|
|
51
|
+
const namePart = parts.slice(0, parts.length - 2).join('.');
|
|
52
|
+
if (!namePart || !type || !ext)
|
|
53
|
+
return null;
|
|
54
|
+
if (!ABAPGIT_TYPES.has(type))
|
|
55
|
+
return null;
|
|
56
|
+
return { name: namePart.toUpperCase(), type: type.toUpperCase() };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Walk `src/` under cwd and surface all abapGit objects found. Returns
|
|
60
|
+
* the abapGit DomainObjectIndex variant. Never throws; missing src/ →
|
|
61
|
+
* empty objects array.
|
|
62
|
+
*/
|
|
63
|
+
export async function extractAbapGit(cwd) {
|
|
64
|
+
const objects = await collectAbapObjects(cwd);
|
|
65
|
+
return { type: 'abapgit', objects };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Shared walker used by abapgit + abap-cloud extractors. Walks src/,
|
|
69
|
+
* collects each file matching the abapGit convention, dedupes by
|
|
70
|
+
* (name, type) so the same object's multiple files (e.g. .clas.abap +
|
|
71
|
+
* .clas.locals_imp.abap) collapse to one entry. Exported for reuse.
|
|
72
|
+
*/
|
|
73
|
+
export async function collectAbapObjects(cwd) {
|
|
74
|
+
const srcDir = path.join(cwd, 'src');
|
|
75
|
+
const seen = new Map();
|
|
76
|
+
let budget = MAX_ENTRIES;
|
|
77
|
+
async function walk(dir, packageHint) {
|
|
78
|
+
if (budget <= 0)
|
|
79
|
+
return;
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const ent of entries) {
|
|
88
|
+
if (budget <= 0)
|
|
89
|
+
return;
|
|
90
|
+
budget--;
|
|
91
|
+
if (ent.isDirectory()) {
|
|
92
|
+
// The directory name typically IS a package in abapGit's serializer.
|
|
93
|
+
// Use this folder as the package hint for files contained directly
|
|
94
|
+
// under it; nested directories override with their own name.
|
|
95
|
+
const nextPackage = ent.name.toUpperCase();
|
|
96
|
+
await walk(path.join(dir, ent.name), nextPackage);
|
|
97
|
+
}
|
|
98
|
+
else if (ent.isFile()) {
|
|
99
|
+
const parsed = parseAbapFilename(ent.name);
|
|
100
|
+
if (!parsed)
|
|
101
|
+
continue;
|
|
102
|
+
const key = `${parsed.name}\0${parsed.type}`;
|
|
103
|
+
if (seen.has(key))
|
|
104
|
+
continue;
|
|
105
|
+
const relPath = path
|
|
106
|
+
.relative(cwd, path.join(dir, ent.name))
|
|
107
|
+
.replace(/\\/g, '/');
|
|
108
|
+
seen.set(key, {
|
|
109
|
+
name: parsed.name,
|
|
110
|
+
type: parsed.type,
|
|
111
|
+
path: relPath,
|
|
112
|
+
package: packageHint,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const stat = await fs.stat(srcDir);
|
|
119
|
+
if (!stat.isDirectory())
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
await walk(srcDir, null);
|
|
126
|
+
// Stable order: by name then type so consumers + tests get a predictable list.
|
|
127
|
+
return [...seen.values()].sort((a, b) => {
|
|
128
|
+
if (a.name !== b.name)
|
|
129
|
+
return a.name.localeCompare(b.name);
|
|
130
|
+
return a.type.localeCompare(b.type);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Type segments abapGit emits as the second-to-last filename token.
|
|
135
|
+
* Lowercased here; matched against lowercased filenames. Sourced from
|
|
136
|
+
* abapGit's serializer module list — a comprehensive set covering the
|
|
137
|
+
* common object types Z/Y custom code typically produces.
|
|
138
|
+
*/
|
|
139
|
+
const ABAPGIT_TYPES = new Set([
|
|
140
|
+
'prog', // program
|
|
141
|
+
'clas', // class
|
|
142
|
+
'intf', // interface
|
|
143
|
+
'fugr', // function group
|
|
144
|
+
'tabl', // table (DDIC)
|
|
145
|
+
'dtel', // data element
|
|
146
|
+
'doma', // domain
|
|
147
|
+
'ttyp', // table type
|
|
148
|
+
'view', // DDIC view (classic)
|
|
149
|
+
'ddls', // CDS DDL source
|
|
150
|
+
'ddlx', // CDS metadata extension
|
|
151
|
+
'dcls', // CDS access control / DCL
|
|
152
|
+
'bdef', // RAP behavior definition
|
|
153
|
+
'srvd', // RAP service definition
|
|
154
|
+
'srvb', // RAP service binding
|
|
155
|
+
'devc', // package
|
|
156
|
+
'msag', // message class
|
|
157
|
+
'tran', // transaction
|
|
158
|
+
'xslt', // XSL transformation
|
|
159
|
+
'enho', // enhancement implementation
|
|
160
|
+
'enhs', // enhancement spot
|
|
161
|
+
'idoc', // IDoc type
|
|
162
|
+
'doct', // documentation
|
|
163
|
+
'auth', // authorization object
|
|
164
|
+
'shlp', // search help
|
|
165
|
+
'iarp', // IDoc reduction
|
|
166
|
+
'wdya', // Web Dynpro application
|
|
167
|
+
'wdyn', // Web Dynpro component
|
|
168
|
+
'sicf', // SICF service node
|
|
169
|
+
'pinf', // package interface
|
|
170
|
+
'iwsv', // OData service
|
|
171
|
+
'iwmo', // OData model
|
|
172
|
+
'sots', // OTR string short text
|
|
173
|
+
'snro', // number range object
|
|
174
|
+
'styl', // SAPscript style
|
|
175
|
+
'form', // SAPscript form
|
|
176
|
+
'fdt0', // BRFplus function
|
|
177
|
+
]);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CAP (SAP Cloud Application Programming) Node domain extractor.
|
|
3
|
+
*
|
|
4
|
+
* CAP projects organize CDS sources under two folders by convention:
|
|
5
|
+
*
|
|
6
|
+
* db/ entity definitions (the persistence model)
|
|
7
|
+
* srv/ service definitions (what the project exposes)
|
|
8
|
+
*
|
|
9
|
+
* For Phase 1E we want a structural outline — enough for the agent to
|
|
10
|
+
* know what services + entities exist and where to find them. We do NOT
|
|
11
|
+
* pull in a full CDS parser; that's a heavy dependency for what amounts
|
|
12
|
+
* to a regex-friendly pattern set. The agent reads full file content on
|
|
13
|
+
* demand via Phase 3's file_read tool when it needs the rest.
|
|
14
|
+
*
|
|
15
|
+
* Extraction approach:
|
|
16
|
+
*
|
|
17
|
+
* - Walk db/ and srv/ recursively, collecting `.cds` files.
|
|
18
|
+
* - For each file, regex-scan for `entity NAME` and `service NAME`
|
|
19
|
+
* declarations. Comments and string literals are ignored at the
|
|
20
|
+
* basic level (single-line `//` comments are stripped before the
|
|
21
|
+
* regex pass; deeper edge cases are left to the agent).
|
|
22
|
+
* - Map services to their exposed entities by file co-location:
|
|
23
|
+
* a service file's entities[] is the union of any `entity` keywords
|
|
24
|
+
* found in the same file (e.g. via `as projection on`).
|
|
25
|
+
* - For DB entities, the `source` field captures the underlying table
|
|
26
|
+
* or external annotation (e.g. `@cds.persistence.exists`) when easy
|
|
27
|
+
* to detect; otherwise null.
|
|
28
|
+
*/
|
|
29
|
+
import { promises as fs } from 'node:fs';
|
|
30
|
+
import * as path from 'node:path';
|
|
31
|
+
const MAX_CDS_FILES = 1000;
|
|
32
|
+
const MAX_FILE_SIZE_BYTES = 1_000_000; // 1 MB; CDS files past this are unusual.
|
|
33
|
+
/**
|
|
34
|
+
* Walk db/ + srv/ under cwd and return the cap-node DomainObjectIndex
|
|
35
|
+
* variant. Never throws; missing folders → empty arrays.
|
|
36
|
+
*/
|
|
37
|
+
export async function extractCap(cwd) {
|
|
38
|
+
const services = [];
|
|
39
|
+
const entities = [];
|
|
40
|
+
// srv/ — service definitions + their projected entities.
|
|
41
|
+
for (const file of await findCdsFiles(path.join(cwd, 'srv'))) {
|
|
42
|
+
const text = await readFileSafe(file);
|
|
43
|
+
if (text === null)
|
|
44
|
+
continue;
|
|
45
|
+
const stripped = stripLineComments(text);
|
|
46
|
+
const rel = relUnix(cwd, file);
|
|
47
|
+
const serviceNames = extractMatches(stripped, /\bservice\s+([A-Za-z_][A-Za-z0-9_.]*)/g);
|
|
48
|
+
const entityNames = extractMatches(stripped, /\bentity\s+([A-Za-z_][A-Za-z0-9_.]*)/g);
|
|
49
|
+
// A srv/ file can declare zero, one, or multiple services. The
|
|
50
|
+
// entities found in the file are projected by the service(s) in it.
|
|
51
|
+
for (const name of serviceNames) {
|
|
52
|
+
services.push({ name, path: rel, entities: [...entityNames] });
|
|
53
|
+
}
|
|
54
|
+
// srv/ files often inline-define projection entities too; surface
|
|
55
|
+
// those as entities anchored at the srv file.
|
|
56
|
+
for (const name of entityNames) {
|
|
57
|
+
entities.push({ name, path: rel, source: null });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// db/ — persistence-side entities.
|
|
61
|
+
for (const file of await findCdsFiles(path.join(cwd, 'db'))) {
|
|
62
|
+
const text = await readFileSafe(file);
|
|
63
|
+
if (text === null)
|
|
64
|
+
continue;
|
|
65
|
+
const stripped = stripLineComments(text);
|
|
66
|
+
const rel = relUnix(cwd, file);
|
|
67
|
+
const entityNames = extractMatches(stripped, /\bentity\s+([A-Za-z_][A-Za-z0-9_.]*)/g);
|
|
68
|
+
const persistenceExists = /@cds\.persistence\.exists/.test(stripped);
|
|
69
|
+
for (const name of entityNames) {
|
|
70
|
+
entities.push({
|
|
71
|
+
name,
|
|
72
|
+
path: rel,
|
|
73
|
+
source: persistenceExists ? 'external-table' : null,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Stable order for tests + diff-friendliness.
|
|
78
|
+
services.sort(compareByNameThenPath);
|
|
79
|
+
entities.sort(compareByNameThenPath);
|
|
80
|
+
return { type: 'cap-node', services, entities };
|
|
81
|
+
}
|
|
82
|
+
/* ── Helpers ────────────────────────────────────────────────────────── */
|
|
83
|
+
async function findCdsFiles(dir) {
|
|
84
|
+
const out = [];
|
|
85
|
+
let budget = MAX_CDS_FILES;
|
|
86
|
+
async function walk(d) {
|
|
87
|
+
if (budget <= 0)
|
|
88
|
+
return;
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = await fs.readdir(d, { withFileTypes: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const ent of entries) {
|
|
97
|
+
if (budget <= 0)
|
|
98
|
+
return;
|
|
99
|
+
if (ent.isDirectory()) {
|
|
100
|
+
if (ent.name === 'node_modules' || ent.name === '.git')
|
|
101
|
+
continue;
|
|
102
|
+
await walk(path.join(d, ent.name));
|
|
103
|
+
}
|
|
104
|
+
else if (ent.isFile() && ent.name.toLowerCase().endsWith('.cds')) {
|
|
105
|
+
out.push(path.join(d, ent.name));
|
|
106
|
+
budget--;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const stat = await fs.stat(dir);
|
|
112
|
+
if (!stat.isDirectory())
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
await walk(dir);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
async function readFileSafe(p) {
|
|
122
|
+
try {
|
|
123
|
+
const stat = await fs.stat(p);
|
|
124
|
+
if (stat.size > MAX_FILE_SIZE_BYTES)
|
|
125
|
+
return null;
|
|
126
|
+
return await fs.readFile(p, 'utf-8');
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Remove single-line `//` comments. Block comments and CDS doc comments
|
|
134
|
+
* (`/** ... *\/`) are left alone — false positives there are rare and
|
|
135
|
+
* the regex extraction tolerates noise (it just over-matches a little).
|
|
136
|
+
*/
|
|
137
|
+
function stripLineComments(text) {
|
|
138
|
+
return text
|
|
139
|
+
.split('\n')
|
|
140
|
+
.map((line) => {
|
|
141
|
+
const idx = line.indexOf('//');
|
|
142
|
+
return idx === -1 ? line : line.slice(0, idx);
|
|
143
|
+
})
|
|
144
|
+
.join('\n');
|
|
145
|
+
}
|
|
146
|
+
function extractMatches(text, re) {
|
|
147
|
+
const out = [];
|
|
148
|
+
let m;
|
|
149
|
+
// Defensive copy — re may be reused across files within one extractor call.
|
|
150
|
+
const local = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
151
|
+
while ((m = local.exec(text)) !== null) {
|
|
152
|
+
if (m[1])
|
|
153
|
+
out.push(m[1]);
|
|
154
|
+
}
|
|
155
|
+
return [...new Set(out)];
|
|
156
|
+
}
|
|
157
|
+
function relUnix(cwd, abs) {
|
|
158
|
+
return path.relative(cwd, abs).replace(/\\/g, '/');
|
|
159
|
+
}
|
|
160
|
+
function compareByNameThenPath(a, b) {
|
|
161
|
+
if (a.name !== b.name)
|
|
162
|
+
return a.name.localeCompare(b.name);
|
|
163
|
+
return a.path.localeCompare(b.path);
|
|
164
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fiori UI5 domain extractor.
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the structural pieces of a UI5 project: routes (from
|
|
5
|
+
* manifest.json), controllers (with linked views + i18n keys),
|
|
6
|
+
* views (with linked controllers + bound model paths), and the i18n
|
|
7
|
+
* key catalog from the .properties files.
|
|
8
|
+
*
|
|
9
|
+
* What we extract — and why this depth:
|
|
10
|
+
*
|
|
11
|
+
* - manifest.json: parsed JSON. The agent uses routing to navigate
|
|
12
|
+
* "where does the route 'detail' end up?" without re-reading the
|
|
13
|
+
* manifest each turn.
|
|
14
|
+
* - controllers (.controller.js): regex-based scan for the canonical
|
|
15
|
+
* `getResourceBundle().getText("key")` pattern. We also do a
|
|
16
|
+
* pragmatic extraction of `.getText('key')` so the linked-i18n
|
|
17
|
+
* story works for projects that cache the bundle in a property.
|
|
18
|
+
* - views (.view.xml): controllerName attribute + simple model-binding
|
|
19
|
+
* scan (`{path: '/Worklist/items'}` plus `"{/Worklist/items}"` style).
|
|
20
|
+
* - i18n (.properties): line-based key=value harvest with comment skip.
|
|
21
|
+
*
|
|
22
|
+
* Discipline: best-effort, no XML / JS parsers as dependencies. The
|
|
23
|
+
* model can read a controller in full via Phase 3's file_read for
|
|
24
|
+
* deep cases. We aim to give the model what it needs to navigate;
|
|
25
|
+
* we do NOT aim to type-check or fully understand UI5 sources.
|
|
26
|
+
*/
|
|
27
|
+
import { promises as fs } from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
const MAX_FILES_PER_CATEGORY = 500;
|
|
30
|
+
const MAX_FILE_SIZE_BYTES = 2_000_000; // 2 MB
|
|
31
|
+
/**
|
|
32
|
+
* Extract Fiori domain objects from a UI5 project rooted at `cwd`.
|
|
33
|
+
* Expects the standard `webapp/` layout. Missing webapp/ → empty
|
|
34
|
+
* arrays. Never throws.
|
|
35
|
+
*/
|
|
36
|
+
export async function extractFiori(cwd) {
|
|
37
|
+
const webapp = path.join(cwd, 'webapp');
|
|
38
|
+
const empty = {
|
|
39
|
+
type: 'fiori-ui5',
|
|
40
|
+
controllers: [],
|
|
41
|
+
views: [],
|
|
42
|
+
routes: [],
|
|
43
|
+
i18nKeys: [],
|
|
44
|
+
};
|
|
45
|
+
if (!(await directoryExists(webapp)))
|
|
46
|
+
return empty;
|
|
47
|
+
const [routes, controllers, viewsRaw, i18nKeys] = await Promise.all([
|
|
48
|
+
extractRoutes(webapp, cwd),
|
|
49
|
+
extractControllers(webapp, cwd),
|
|
50
|
+
extractViewsRaw(webapp, cwd),
|
|
51
|
+
extractI18nKeys(webapp),
|
|
52
|
+
]);
|
|
53
|
+
// Resolve controllerName attribute → controller filesystem path. Where
|
|
54
|
+
// the attribute is absent, fall back to basename match
|
|
55
|
+
// (Worklist.view.xml ↔ Worklist.controller.js). Each side gets its
|
|
56
|
+
// counterpart's path when one is found.
|
|
57
|
+
const views = linkControllersAndViews(controllers, viewsRaw);
|
|
58
|
+
return { type: 'fiori-ui5', controllers, views, routes, i18nKeys };
|
|
59
|
+
}
|
|
60
|
+
/* ── manifest.json → routes ────────────────────────────────────────── */
|
|
61
|
+
async function extractRoutes(webapp, cwd) {
|
|
62
|
+
const manifestPath = path.join(webapp, 'manifest.json');
|
|
63
|
+
const text = await readFileSafe(manifestPath);
|
|
64
|
+
if (text === null)
|
|
65
|
+
return [];
|
|
66
|
+
let manifest;
|
|
67
|
+
try {
|
|
68
|
+
manifest = JSON.parse(text);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
void cwd; // path resolution stays inside webapp/ for routes
|
|
74
|
+
const ui5 = manifest['sap.ui5'];
|
|
75
|
+
if (!ui5 || typeof ui5 !== 'object')
|
|
76
|
+
return [];
|
|
77
|
+
const routing = ui5.routing;
|
|
78
|
+
if (!routing || typeof routing !== 'object')
|
|
79
|
+
return [];
|
|
80
|
+
const rawRoutes = routing.routes;
|
|
81
|
+
const out = [];
|
|
82
|
+
if (Array.isArray(rawRoutes)) {
|
|
83
|
+
for (const entry of rawRoutes) {
|
|
84
|
+
const route = parseRouteEntry(entry);
|
|
85
|
+
if (route)
|
|
86
|
+
out.push(route);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (rawRoutes && typeof rawRoutes === 'object') {
|
|
90
|
+
// Some apps declare routes as an object keyed by name. Coerce.
|
|
91
|
+
for (const [name, entry] of Object.entries(rawRoutes)) {
|
|
92
|
+
const route = parseRouteEntry(entry, name);
|
|
93
|
+
if (route)
|
|
94
|
+
out.push(route);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function parseRouteEntry(entry, fallbackName) {
|
|
100
|
+
if (!entry || typeof entry !== 'object')
|
|
101
|
+
return null;
|
|
102
|
+
const e = entry;
|
|
103
|
+
const name = typeof e.name === 'string' ? e.name : (fallbackName ?? null);
|
|
104
|
+
if (name === null)
|
|
105
|
+
return null;
|
|
106
|
+
const pattern = typeof e.pattern === 'string' ? e.pattern : '';
|
|
107
|
+
const targets = coerceTargets(e.target);
|
|
108
|
+
return { name, pattern, targets };
|
|
109
|
+
}
|
|
110
|
+
function coerceTargets(raw) {
|
|
111
|
+
if (typeof raw === 'string')
|
|
112
|
+
return [raw];
|
|
113
|
+
if (Array.isArray(raw))
|
|
114
|
+
return raw.filter((x) => typeof x === 'string');
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
/* ── controllers/*.controller.js ──────────────────────────────────── */
|
|
118
|
+
async function extractControllers(webapp, cwd) {
|
|
119
|
+
const dir = path.join(webapp, 'controller');
|
|
120
|
+
if (!(await directoryExists(dir)))
|
|
121
|
+
return [];
|
|
122
|
+
const files = await listFiles(dir, (n) => n.toLowerCase().endsWith('.controller.js'));
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const file of files.slice(0, MAX_FILES_PER_CATEGORY)) {
|
|
125
|
+
const text = await readFileSafe(file);
|
|
126
|
+
if (text === null)
|
|
127
|
+
continue;
|
|
128
|
+
const name = path.basename(file).replace(/\.controller\.js$/i, '');
|
|
129
|
+
const i18nKeys = extractI18nKeyReferences(text);
|
|
130
|
+
out.push({
|
|
131
|
+
name,
|
|
132
|
+
path: relUnix(cwd, file),
|
|
133
|
+
viewPath: null, // populated by linkControllersAndViews
|
|
134
|
+
i18nKeys,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
function extractI18nKeyReferences(jsSource) {
|
|
140
|
+
// Match `getText("key")` and `getText('key')`. Conservative — we don't
|
|
141
|
+
// try to resolve dynamic key construction. The agent can read the
|
|
142
|
+
// controller source for the harder cases.
|
|
143
|
+
const pattern = /getText\(\s*['"]([A-Za-z0-9._-]+)['"]/g;
|
|
144
|
+
const out = new Set();
|
|
145
|
+
let m;
|
|
146
|
+
while ((m = pattern.exec(jsSource)) !== null) {
|
|
147
|
+
if (m[1])
|
|
148
|
+
out.add(m[1]);
|
|
149
|
+
}
|
|
150
|
+
return [...out].sort();
|
|
151
|
+
}
|
|
152
|
+
async function extractViewsRaw(webapp, cwd) {
|
|
153
|
+
const dir = path.join(webapp, 'view');
|
|
154
|
+
if (!(await directoryExists(dir)))
|
|
155
|
+
return [];
|
|
156
|
+
const files = await listFiles(dir, (n) => n.toLowerCase().endsWith('.view.xml'));
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const file of files.slice(0, MAX_FILES_PER_CATEGORY)) {
|
|
159
|
+
const text = await readFileSafe(file);
|
|
160
|
+
if (text === null)
|
|
161
|
+
continue;
|
|
162
|
+
const name = path.basename(file).replace(/\.view\.xml$/i, '');
|
|
163
|
+
const controllerNameAttr = extractControllerNameAttr(text);
|
|
164
|
+
const boundModelPaths = extractBoundModelPaths(text);
|
|
165
|
+
out.push({
|
|
166
|
+
controllerNameAttr,
|
|
167
|
+
view: {
|
|
168
|
+
name,
|
|
169
|
+
path: relUnix(cwd, file),
|
|
170
|
+
controllerPath: null,
|
|
171
|
+
boundModelPaths,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function extractControllerNameAttr(xml) {
|
|
178
|
+
const m = /controllerName\s*=\s*"([^"]+)"/.exec(xml);
|
|
179
|
+
return m && m[1] ? m[1] : null;
|
|
180
|
+
}
|
|
181
|
+
function extractBoundModelPaths(xml) {
|
|
182
|
+
// Three binding forms we want to capture:
|
|
183
|
+
// 1. Object form: items="{path: '/Worklist/items', formatter: 'fn'}"
|
|
184
|
+
// 2. Simple form: items="{/Worklist/items}"
|
|
185
|
+
// 3. Composite/multi: text="prefix {/name} suffix" or text="{/a} {/b}"
|
|
186
|
+
//
|
|
187
|
+
// Form 1 needs the explicit `path:` keyword scan because the value
|
|
188
|
+
// could appear anywhere inside the object literal.
|
|
189
|
+
//
|
|
190
|
+
// Forms 2 and 3 share a single rule: any `{/...}` token inside the
|
|
191
|
+
// XML where the inner content starts with `/` (absolute path). The
|
|
192
|
+
// leading `/` requirement excludes object-form fragments (those start
|
|
193
|
+
// with `path` etc.) and named-model bindings (those contain `>`,
|
|
194
|
+
// e.g. `{i18n>title}`).
|
|
195
|
+
const out = new Set();
|
|
196
|
+
const pathExpr = /\{\s*path\s*:\s*['"]([^'"]+)['"]/g;
|
|
197
|
+
// Match `{/<absolute-path>}` anywhere — captures simple, composite,
|
|
198
|
+
// and multi-bindings inside the same attribute. The inner character
|
|
199
|
+
// class excludes braces (they delimit) and `>` (alias bindings).
|
|
200
|
+
const absoluteBinding = /\{(\/[^{}>]+)\}/g;
|
|
201
|
+
let m;
|
|
202
|
+
while ((m = pathExpr.exec(xml)) !== null) {
|
|
203
|
+
if (m[1] && !m[1].includes('>'))
|
|
204
|
+
out.add(m[1]);
|
|
205
|
+
}
|
|
206
|
+
while ((m = absoluteBinding.exec(xml)) !== null) {
|
|
207
|
+
const v = m[1];
|
|
208
|
+
if (!v)
|
|
209
|
+
continue;
|
|
210
|
+
out.add(v.trim());
|
|
211
|
+
}
|
|
212
|
+
return [...out].sort();
|
|
213
|
+
}
|
|
214
|
+
/* ── i18n/*.properties ────────────────────────────────────────────── */
|
|
215
|
+
async function extractI18nKeys(webapp) {
|
|
216
|
+
const dir = path.join(webapp, 'i18n');
|
|
217
|
+
if (!(await directoryExists(dir)))
|
|
218
|
+
return [];
|
|
219
|
+
const files = await listFiles(dir, (n) => n.toLowerCase().endsWith('.properties'));
|
|
220
|
+
const seen = new Set();
|
|
221
|
+
for (const file of files) {
|
|
222
|
+
const text = await readFileSafe(file);
|
|
223
|
+
if (text === null)
|
|
224
|
+
continue;
|
|
225
|
+
for (const rawLine of text.split('\n')) {
|
|
226
|
+
const line = rawLine.replace(/^/, '').trim();
|
|
227
|
+
if (line.length === 0)
|
|
228
|
+
continue;
|
|
229
|
+
if (line.startsWith('#') || line.startsWith('!'))
|
|
230
|
+
continue;
|
|
231
|
+
// key=value or key:value (Java properties allow either)
|
|
232
|
+
const eq = line.indexOf('=');
|
|
233
|
+
const colon = line.indexOf(':');
|
|
234
|
+
const sep = eq === -1 ? colon : colon === -1 ? eq : Math.min(eq, colon);
|
|
235
|
+
if (sep <= 0)
|
|
236
|
+
continue;
|
|
237
|
+
const key = line.slice(0, sep).trim();
|
|
238
|
+
if (key)
|
|
239
|
+
seen.add(key);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return [...seen].sort();
|
|
243
|
+
}
|
|
244
|
+
/* ── Cross-linking ────────────────────────────────────────────────── */
|
|
245
|
+
function linkControllersAndViews(controllers, rawViews) {
|
|
246
|
+
const controllerByName = new Map(controllers.map((c) => [c.name, c]));
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const { view, controllerNameAttr } of rawViews) {
|
|
249
|
+
// The `controllerName` attribute uses module-form notation
|
|
250
|
+
// (e.g. "myapp.controller.Worklist"). Take the trailing segment as
|
|
251
|
+
// the controller name and try to match. Falls back to basename match
|
|
252
|
+
// when the attribute is absent.
|
|
253
|
+
let controllerName;
|
|
254
|
+
if (controllerNameAttr) {
|
|
255
|
+
controllerName = controllerNameAttr.includes('.')
|
|
256
|
+
? controllerNameAttr.slice(controllerNameAttr.lastIndexOf('.') + 1)
|
|
257
|
+
: controllerNameAttr;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
controllerName = view.name;
|
|
261
|
+
}
|
|
262
|
+
const controller = controllerByName.get(controllerName) ?? null;
|
|
263
|
+
out.push({
|
|
264
|
+
...view,
|
|
265
|
+
controllerPath: controller ? controller.path : null,
|
|
266
|
+
});
|
|
267
|
+
if (controller && !controller.viewPath) {
|
|
268
|
+
controller.viewPath = view.path;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
/* ── Filesystem primitives ────────────────────────────────────────── */
|
|
274
|
+
async function directoryExists(p) {
|
|
275
|
+
try {
|
|
276
|
+
const s = await fs.stat(p);
|
|
277
|
+
return s.isDirectory();
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function listFiles(dir, matcher) {
|
|
284
|
+
const out = [];
|
|
285
|
+
let budget = MAX_FILES_PER_CATEGORY * 4;
|
|
286
|
+
async function walk(d) {
|
|
287
|
+
if (budget <= 0)
|
|
288
|
+
return;
|
|
289
|
+
let entries;
|
|
290
|
+
try {
|
|
291
|
+
entries = await fs.readdir(d, { withFileTypes: true });
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
for (const ent of entries) {
|
|
297
|
+
if (budget <= 0)
|
|
298
|
+
return;
|
|
299
|
+
budget--;
|
|
300
|
+
if (ent.isDirectory()) {
|
|
301
|
+
if (ent.name === 'node_modules' || ent.name === '.git')
|
|
302
|
+
continue;
|
|
303
|
+
await walk(path.join(d, ent.name));
|
|
304
|
+
}
|
|
305
|
+
else if (ent.isFile() && matcher(ent.name)) {
|
|
306
|
+
out.push(path.join(d, ent.name));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
await walk(dir);
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
async function readFileSafe(p) {
|
|
314
|
+
try {
|
|
315
|
+
const stat = await fs.stat(p);
|
|
316
|
+
if (stat.size > MAX_FILE_SIZE_BYTES)
|
|
317
|
+
return null;
|
|
318
|
+
return await fs.readFile(p, 'utf-8');
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function relUnix(cwd, abs) {
|
|
325
|
+
return path.relative(cwd, abs).replace(/\\/g, '/');
|
|
326
|
+
}
|