scene-capability-engine 3.0.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/CHANGELOG.md +2513 -0
- package/LICENSE +21 -0
- package/README.md +765 -0
- package/README.zh.md +630 -0
- package/bin/kiro-spec-engine.js +796 -0
- package/bin/kse.js +3 -0
- package/bin/sce.js +3 -0
- package/bin/sco.js +3 -0
- package/docs/331-poc-adaptation-roadmap.md +156 -0
- package/docs/331-poc-dual-track-integration-guide.md +120 -0
- package/docs/331-poc-weekly-delivery-checklist.md +52 -0
- package/docs/OFFLINE_INSTALL.md +96 -0
- package/docs/README.md +279 -0
- package/docs/adopt-migration-guide.md +599 -0
- package/docs/adoption-guide.md +616 -0
- package/docs/agent-hooks-analysis.md +815 -0
- package/docs/architecture.md +733 -0
- package/docs/articles/ai-driven-development-philosophy-and-practice-review.md +208 -0
- package/docs/articles/ai-driven-development-philosophy-and-practice.en.md +459 -0
- package/docs/articles/ai-driven-development-philosophy-and-practice.md +492 -0
- package/docs/autonomous-control-guide.md +851 -0
- package/docs/command-reference.md +1368 -0
- package/docs/community.md +115 -0
- package/docs/cross-tool-guide.md +555 -0
- package/docs/developer-guide.md +619 -0
- package/docs/document-governance.md +865 -0
- package/docs/environment-management-guide.md +526 -0
- package/docs/examples/add-export-command/design.md +194 -0
- package/docs/examples/add-export-command/requirements.md +110 -0
- package/docs/examples/add-export-command/tasks.md +88 -0
- package/docs/examples/add-rest-api/design.md +855 -0
- package/docs/examples/add-rest-api/requirements.md +323 -0
- package/docs/examples/add-rest-api/tasks.md +355 -0
- package/docs/examples/add-user-dashboard/design.md +192 -0
- package/docs/examples/add-user-dashboard/requirements.md +143 -0
- package/docs/examples/add-user-dashboard/tasks.md +91 -0
- package/docs/faq.md +697 -0
- package/docs/handoffs/evidence/ontology/moqui-template-baseline-2026-02-17-232922.json +156 -0
- package/docs/handoffs/evidence/ontology/moqui-template-baseline-2026-02-17-232922.md +24 -0
- package/docs/images/wechat-qr.png +0 -0
- package/docs/integration-modes.md +529 -0
- package/docs/integration-philosophy.md +313 -0
- package/docs/knowledge-management-guide.md +263 -0
- package/docs/manual-workflows-guide.md +418 -0
- package/docs/moqui-capability-matrix.md +73 -0
- package/docs/moqui-template-core-library-playbook.md +109 -0
- package/docs/multi-agent-coordination-guide.md +553 -0
- package/docs/multi-repo-management-guide.md +1344 -0
- package/docs/quick-start-with-ai-tools.md +375 -0
- package/docs/quick-start.md +146 -0
- package/docs/release-checklist.md +121 -0
- package/docs/releases/README.md +13 -0
- package/docs/releases/v1.46.2-validation.md +45 -0
- package/docs/releases/v1.46.2.md +50 -0
- package/docs/scene-runtime-guide.md +347 -0
- package/docs/spec-collaboration-guide.md +369 -0
- package/docs/spec-locking-guide.md +225 -0
- package/docs/spec-numbering-guide.md +348 -0
- package/docs/spec-workflow.md +519 -0
- package/docs/steering-strategy-guide.md +196 -0
- package/docs/team-collaboration-guide.md +465 -0
- package/docs/testing-strategy.md +272 -0
- package/docs/tools/claude-guide.md +654 -0
- package/docs/tools/cursor-guide.md +706 -0
- package/docs/tools/generic-guide.md +446 -0
- package/docs/tools/kiro-guide.md +308 -0
- package/docs/tools/vscode-guide.md +445 -0
- package/docs/tools/windsurf-guide.md +391 -0
- package/docs/troubleshooting.md +1135 -0
- package/docs/upgrade-guide.md +639 -0
- package/docs/value-observability-guide.md +127 -0
- package/docs/zh/README.md +341 -0
- package/docs/zh/quick-start.md +764 -0
- package/docs/zh/release-checklist.md +121 -0
- package/docs/zh/releases/README.md +13 -0
- package/docs/zh/releases/v1.46.2-validation.md +45 -0
- package/docs/zh/releases/v1.46.2.md +50 -0
- package/docs/zh/spec-numbering-guide.md +348 -0
- package/docs/zh/tools/claude-guide.md +349 -0
- package/docs/zh/tools/cursor-guide.md +281 -0
- package/docs/zh/tools/generic-guide.md +499 -0
- package/docs/zh/tools/kiro-guide.md +342 -0
- package/docs/zh/tools/vscode-guide.md +449 -0
- package/docs/zh/tools/windsurf-guide.md +378 -0
- package/docs/zh/value-observability-guide.md +127 -0
- package/docs//344/272/244/344/273/230/346/270/205/345/215/225.md +75 -0
- package/lib/adoption/adoption-logger.js +487 -0
- package/lib/adoption/adoption-strategy.js +538 -0
- package/lib/adoption/backup-manager.js +420 -0
- package/lib/adoption/conflict-resolver.js +410 -0
- package/lib/adoption/detection-engine.js +275 -0
- package/lib/adoption/diff-viewer.js +226 -0
- package/lib/adoption/error-formatter.js +509 -0
- package/lib/adoption/file-classifier.js +385 -0
- package/lib/adoption/progress-reporter.js +534 -0
- package/lib/adoption/smart-orchestrator.js +470 -0
- package/lib/adoption/strategy-selector.js +218 -0
- package/lib/adoption/summary-generator.js +493 -0
- package/lib/adoption/template-sync.js +605 -0
- package/lib/auto/autonomous-engine.js +485 -0
- package/lib/auto/checkpoint-manager.js +300 -0
- package/lib/auto/close-loop-runner.js +2476 -0
- package/lib/auto/config-schema.js +176 -0
- package/lib/auto/decision-engine.js +344 -0
- package/lib/auto/error-recovery-manager.js +580 -0
- package/lib/auto/goal-decomposer.js +278 -0
- package/lib/auto/progress-tracker.js +502 -0
- package/lib/auto/safety-manager.js +186 -0
- package/lib/auto/semantic-decomposer.js +137 -0
- package/lib/auto/state-manager.js +126 -0
- package/lib/auto/task-queue-manager.js +340 -0
- package/lib/backup/backup-system.js +372 -0
- package/lib/backup/selective-backup.js +207 -0
- package/lib/collab/agent-registry.js +240 -0
- package/lib/collab/collab-manager.js +285 -0
- package/lib/collab/contract-manager.js +320 -0
- package/lib/collab/coordinator.js +370 -0
- package/lib/collab/dependency-manager.js +280 -0
- package/lib/collab/index.js +20 -0
- package/lib/collab/integration-manager.js +202 -0
- package/lib/collab/merge-coordinator.js +252 -0
- package/lib/collab/metadata-manager.js +233 -0
- package/lib/collab/multi-agent-config.js +120 -0
- package/lib/collab/spec-lifecycle-manager.js +304 -0
- package/lib/collab/sync-barrier.js +88 -0
- package/lib/collab/visualizer.js +208 -0
- package/lib/commands/adopt.js +749 -0
- package/lib/commands/auto.js +19559 -0
- package/lib/commands/collab.js +275 -0
- package/lib/commands/context.js +99 -0
- package/lib/commands/docs.js +808 -0
- package/lib/commands/doctor.js +273 -0
- package/lib/commands/env.js +420 -0
- package/lib/commands/knowledge.js +309 -0
- package/lib/commands/lock.js +235 -0
- package/lib/commands/ops.js +409 -0
- package/lib/commands/orchestrate.js +446 -0
- package/lib/commands/prompt.js +105 -0
- package/lib/commands/repo.js +118 -0
- package/lib/commands/rollback.js +219 -0
- package/lib/commands/scene.js +15549 -0
- package/lib/commands/spec-bootstrap.js +147 -0
- package/lib/commands/spec-gate.js +157 -0
- package/lib/commands/spec-pipeline.js +205 -0
- package/lib/commands/status.js +321 -0
- package/lib/commands/task.js +199 -0
- package/lib/commands/templates.js +654 -0
- package/lib/commands/upgrade.js +231 -0
- package/lib/commands/value.js +569 -0
- package/lib/commands/watch.js +684 -0
- package/lib/commands/workflows.js +240 -0
- package/lib/commands/workspace-multi.js +325 -0
- package/lib/commands/workspace.js +189 -0
- package/lib/context/context-exporter.js +378 -0
- package/lib/context/prompt-generator.js +482 -0
- package/lib/data/moqui-capability-lexicon.json +45 -0
- package/lib/environment/backup-system.js +189 -0
- package/lib/environment/environment-manager.js +379 -0
- package/lib/environment/environment-registry.js +168 -0
- package/lib/gitignore/gitignore-backup.js +229 -0
- package/lib/gitignore/gitignore-detector.js +239 -0
- package/lib/gitignore/gitignore-integration.js +267 -0
- package/lib/gitignore/gitignore-transformer.js +193 -0
- package/lib/gitignore/layered-rules-template.js +42 -0
- package/lib/governance/archive-tool.js +284 -0
- package/lib/governance/cleanup-tool.js +237 -0
- package/lib/governance/config-manager.js +186 -0
- package/lib/governance/diagnostic-engine.js +271 -0
- package/lib/governance/doc-reference-checker.js +200 -0
- package/lib/governance/execution-logger.js +243 -0
- package/lib/governance/file-scanner.js +285 -0
- package/lib/governance/hooks-manager.js +333 -0
- package/lib/governance/reporter.js +337 -0
- package/lib/governance/validation-engine.js +181 -0
- package/lib/i18n.js +79 -0
- package/lib/knowledge/entry-manager.js +208 -0
- package/lib/knowledge/index-manager.js +261 -0
- package/lib/knowledge/knowledge-manager.js +273 -0
- package/lib/knowledge/template-manager.js +191 -0
- package/lib/lock/index.js +21 -0
- package/lib/lock/lock-file.js +192 -0
- package/lib/lock/lock-manager.js +321 -0
- package/lib/lock/machine-identifier.js +135 -0
- package/lib/lock/steering-file-lock.js +207 -0
- package/lib/lock/task-lock-manager.js +345 -0
- package/lib/operations/audit-logger.js +293 -0
- package/lib/operations/feedback-manager.js +1147 -0
- package/lib/operations/index.js +23 -0
- package/lib/operations/models/index.js +170 -0
- package/lib/operations/operations-manager.js +151 -0
- package/lib/operations/operations-validator.js +280 -0
- package/lib/operations/permission-manager.js +354 -0
- package/lib/operations/template-loader.js +143 -0
- package/lib/orchestrator/agent-spawner.js +629 -0
- package/lib/orchestrator/bootstrap-prompt-builder.js +236 -0
- package/lib/orchestrator/index.js +19 -0
- package/lib/orchestrator/orchestration-engine.js +1270 -0
- package/lib/orchestrator/orchestrator-config.js +173 -0
- package/lib/orchestrator/status-monitor.js +591 -0
- package/lib/python-checker.js +209 -0
- package/lib/repo/config-manager.js +580 -0
- package/lib/repo/errors/config-error.js +13 -0
- package/lib/repo/errors/git-error.js +15 -0
- package/lib/repo/errors/repo-error.js +14 -0
- package/lib/repo/git-operations.js +181 -0
- package/lib/repo/handlers/.gitkeep +1 -0
- package/lib/repo/handlers/exec-handler.js +155 -0
- package/lib/repo/handlers/health-handler.js +169 -0
- package/lib/repo/handlers/init-handler.js +197 -0
- package/lib/repo/handlers/status-handler.js +176 -0
- package/lib/repo/output-formatter.js +184 -0
- package/lib/repo/path-resolver.js +178 -0
- package/lib/repo/repo-manager.js +514 -0
- package/lib/scene-runtime/audit-emitter.js +59 -0
- package/lib/scene-runtime/binding-plugin-loader.js +351 -0
- package/lib/scene-runtime/binding-registry.js +349 -0
- package/lib/scene-runtime/eval-bridge.js +44 -0
- package/lib/scene-runtime/index.js +19 -0
- package/lib/scene-runtime/moqui-adapter.js +620 -0
- package/lib/scene-runtime/moqui-client.js +606 -0
- package/lib/scene-runtime/moqui-extractor.js +2029 -0
- package/lib/scene-runtime/plan-compiler.js +208 -0
- package/lib/scene-runtime/policy-gate.js +58 -0
- package/lib/scene-runtime/runtime-executor.js +358 -0
- package/lib/scene-runtime/scene-loader.js +96 -0
- package/lib/scene-runtime/scene-ontology.js +959 -0
- package/lib/scene-runtime/scene-template-linter.js +852 -0
- package/lib/scene-runtime/templates/scene-template-erp-query-v0.1.yaml +28 -0
- package/lib/scene-runtime/templates/scene-template-hybrid-shadow-v0.1.yaml +34 -0
- package/lib/spec/bootstrap/context-collector.js +48 -0
- package/lib/spec/bootstrap/draft-generator.js +158 -0
- package/lib/spec/bootstrap/questionnaire-engine.js +70 -0
- package/lib/spec/bootstrap/trace-emitter.js +59 -0
- package/lib/spec/multi-spec-orchestrate.js +93 -0
- package/lib/spec/pipeline/constants.js +6 -0
- package/lib/spec/pipeline/stage-adapters.js +118 -0
- package/lib/spec/pipeline/stage-runner.js +146 -0
- package/lib/spec/pipeline/state-store.js +119 -0
- package/lib/spec-gate/engine/gate-engine.js +165 -0
- package/lib/spec-gate/policy/default-policy.js +22 -0
- package/lib/spec-gate/policy/policy-loader.js +103 -0
- package/lib/spec-gate/result-emitter.js +81 -0
- package/lib/spec-gate/rules/default-rules.js +156 -0
- package/lib/spec-gate/rules/rule-registry.js +51 -0
- package/lib/steering/adoption-config.js +164 -0
- package/lib/steering/compliance-auto-fixer.js +204 -0
- package/lib/steering/compliance-cache.js +99 -0
- package/lib/steering/compliance-error-reporter.js +70 -0
- package/lib/steering/context-sync-manager.js +273 -0
- package/lib/steering/index.js +92 -0
- package/lib/steering/spec-steering.js +230 -0
- package/lib/steering/steering-compliance-checker.js +73 -0
- package/lib/steering/steering-loader.js +144 -0
- package/lib/steering/steering-manager.js +289 -0
- package/lib/task/index.js +12 -0
- package/lib/task/task-claimer.js +489 -0
- package/lib/task/task-status-store.js +418 -0
- package/lib/templates/cache-manager.js +440 -0
- package/lib/templates/content-generalizer.js +247 -0
- package/lib/templates/frontmatter-generator.js +128 -0
- package/lib/templates/git-handler.js +471 -0
- package/lib/templates/metadata-collector.js +328 -0
- package/lib/templates/path-utils.js +144 -0
- package/lib/templates/registry-parser.js +505 -0
- package/lib/templates/spec-reader.js +216 -0
- package/lib/templates/template-applicator.js +249 -0
- package/lib/templates/template-creator.js +256 -0
- package/lib/templates/template-error.js +143 -0
- package/lib/templates/template-exporter.js +502 -0
- package/lib/templates/template-manager.js +782 -0
- package/lib/templates/template-validator.js +361 -0
- package/lib/upgrade/migration-engine.js +382 -0
- package/lib/upgrade/migrations/.gitkeep +52 -0
- package/lib/upgrade/migrations/1.0.0-to-1.1.0.js +78 -0
- package/lib/utils/file-diff.js +177 -0
- package/lib/utils/fs-utils.js +274 -0
- package/lib/utils/tool-detector.js +383 -0
- package/lib/utils/validation.js +324 -0
- package/lib/value/gate-summary-emitter.js +99 -0
- package/lib/value/metric-contract-loader.js +210 -0
- package/lib/value/risk-evaluator.js +117 -0
- package/lib/value/weekly-snapshot-builder.js +61 -0
- package/lib/version/version-checker.js +156 -0
- package/lib/version/version-manager.js +327 -0
- package/lib/watch/action-executor.js +458 -0
- package/lib/watch/event-debouncer.js +323 -0
- package/lib/watch/execution-logger.js +550 -0
- package/lib/watch/file-watcher.js +499 -0
- package/lib/watch/presets.js +266 -0
- package/lib/watch/watch-manager.js +533 -0
- package/lib/workspace/multi/global-config.js +150 -0
- package/lib/workspace/multi/index.js +22 -0
- package/lib/workspace/multi/path-utils.js +173 -0
- package/lib/workspace/multi/workspace-context-resolver.js +244 -0
- package/lib/workspace/multi/workspace-registry.js +196 -0
- package/lib/workspace/multi/workspace-state-manager.js +537 -0
- package/lib/workspace/multi/workspace.js +90 -0
- package/lib/workspace/workspace-manager.js +370 -0
- package/lib/workspace/workspace-sync.js +356 -0
- package/locales/en.json +114 -0
- package/locales/zh.json +114 -0
- package/package.json +102 -0
- package/template/.kiro/README.md +247 -0
- package/template/.kiro/hooks/check-spec-on-create.kiro.hook +17 -0
- package/template/.kiro/hooks/run-tests-on-save.kiro.hook +13 -0
- package/template/.kiro/hooks/sync-tasks-on-edit.kiro.hook +16 -0
- package/template/.kiro/specs/SPEC_WORKFLOW_GUIDE.md +134 -0
- package/template/.kiro/steering/CORE_PRINCIPLES.md +133 -0
- package/template/.kiro/steering/CURRENT_CONTEXT.md +30 -0
- package/template/.kiro/steering/ENVIRONMENT.md +35 -0
- package/template/.kiro/steering/RULES_GUIDE.md +46 -0
- package/template/.kiro/templates/operations/default/change-impact.md +112 -0
- package/template/.kiro/templates/operations/default/deployment.md +91 -0
- package/template/.kiro/templates/operations/default/feedback-response.md +269 -0
- package/template/.kiro/templates/operations/default/migration-plan.md +172 -0
- package/template/.kiro/templates/operations/default/monitoring.md +135 -0
- package/template/.kiro/templates/operations/default/operations.md +135 -0
- package/template/.kiro/templates/operations/default/rollback.md +143 -0
- package/template/.kiro/templates/operations/default/tools.yaml +364 -0
- package/template/.kiro/templates/operations/default/troubleshooting.md +123 -0
- package/template/.kiro/tools/backup_manager.py +295 -0
- package/template/.kiro/tools/configuration_manager.py +218 -0
- package/template/.kiro/tools/document_evaluator.py +550 -0
- package/template/.kiro/tools/enhancement_logger.py +168 -0
- package/template/.kiro/tools/error_handler.py +335 -0
- package/template/.kiro/tools/improvement_identifier.py +444 -0
- package/template/.kiro/tools/modification_applicator.py +737 -0
- package/template/.kiro/tools/quality_gate_enforcer.py +207 -0
- package/template/.kiro/tools/quality_scorer.py +305 -0
- package/template/.kiro/tools/report_generator.py +154 -0
- package/template/.kiro/tools/ultrawork_enhancer.py +676 -0
- package/template/.kiro/tools/ultrawork_enhancer_refactored.py +0 -0
- package/template/.kiro/tools/ultrawork_enhancer_v2.py +463 -0
- package/template/.kiro/tools/ultrawork_enhancer_v3.py +606 -0
- package/template/.kiro/tools/workflow_quality_gate.py +100 -0
- package/template/README.md +111 -0
|
@@ -0,0 +1,2029 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const MoquiClient = require('./moqui-client');
|
|
5
|
+
const { loadAdapterConfig, validateAdapterConfig } = require('./moqui-adapter');
|
|
6
|
+
|
|
7
|
+
// ─── Constants ─────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const SUPPORTED_PATTERNS = ['crud', 'query', 'workflow'];
|
|
10
|
+
|
|
11
|
+
const HEADER_ITEM_SUFFIXES = [
|
|
12
|
+
{ header: 'Header', item: 'Item' },
|
|
13
|
+
{ header: 'Header', item: 'Detail' },
|
|
14
|
+
{ header: 'Master', item: 'Detail' }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const SCENE_API_VERSION = 'kse.scene/v0.2';
|
|
18
|
+
const PACKAGE_API_VERSION = 'kse.scene.package/v0.1';
|
|
19
|
+
|
|
20
|
+
// ─── YAML Serializer ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a string value needs quoting in YAML.
|
|
24
|
+
* Quotes are needed for: empty strings, strings containing special chars,
|
|
25
|
+
* strings that look like booleans or numbers, strings with leading/trailing spaces.
|
|
26
|
+
* @param {string} value - String value to check
|
|
27
|
+
* @returns {boolean} true if quoting is needed
|
|
28
|
+
*/
|
|
29
|
+
function needsYamlQuoting(value) {
|
|
30
|
+
if (value === '') {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Leading or trailing whitespace
|
|
35
|
+
if (value !== value.trim()) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Looks like a boolean
|
|
40
|
+
if (value === 'true' || value === 'false' || value === 'null' ||
|
|
41
|
+
value === 'True' || value === 'False' || value === 'Null' ||
|
|
42
|
+
value === 'TRUE' || value === 'FALSE' || value === 'NULL' ||
|
|
43
|
+
value === 'yes' || value === 'no' || value === 'on' || value === 'off' ||
|
|
44
|
+
value === 'Yes' || value === 'No' || value === 'On' || value === 'Off' ||
|
|
45
|
+
value === 'YES' || value === 'NO' || value === 'ON' || value === 'OFF') {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Looks like a number
|
|
50
|
+
if (/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(value)) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Contains special YAML characters that could cause parsing issues
|
|
55
|
+
if (/[:#\[\]{}&*!|>'"%@`]/.test(value)) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Starts with special characters
|
|
60
|
+
if (/^[-?](\s|$)/.test(value)) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format a scalar value for YAML output.
|
|
69
|
+
* @param {*} value - Value to format
|
|
70
|
+
* @returns {string} Formatted YAML value
|
|
71
|
+
*/
|
|
72
|
+
function formatYamlValue(value) {
|
|
73
|
+
if (value === null || value === undefined) {
|
|
74
|
+
return 'null';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof value === 'boolean') {
|
|
78
|
+
return value ? 'true' : 'false';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof value === 'number') {
|
|
82
|
+
return String(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const str = String(value);
|
|
86
|
+
|
|
87
|
+
if (needsYamlQuoting(str)) {
|
|
88
|
+
// Use double quotes and escape special characters
|
|
89
|
+
const escaped = str
|
|
90
|
+
.replace(/\\/g, '\\\\')
|
|
91
|
+
.replace(/"/g, '\\"')
|
|
92
|
+
.replace(/\n/g, '\\n')
|
|
93
|
+
.replace(/\r/g, '\\r')
|
|
94
|
+
.replace(/\t/g, '\\t');
|
|
95
|
+
return `"${escaped}"`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return str;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Serialize a scene manifest object to YAML string.
|
|
103
|
+
* Uses a minimal built-in serializer (no external deps).
|
|
104
|
+
* Handles nested objects, arrays, string/number/boolean values with 2-space indentation.
|
|
105
|
+
* @param {Object} manifest - Scene manifest object
|
|
106
|
+
* @returns {string} YAML string
|
|
107
|
+
*/
|
|
108
|
+
function serializeManifestToYaml(manifest) {
|
|
109
|
+
if (manifest === null || manifest === undefined) {
|
|
110
|
+
return 'null\n';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof manifest !== 'object') {
|
|
114
|
+
return formatYamlValue(manifest) + '\n';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lines = [];
|
|
118
|
+
serializeObject(manifest, 0, lines);
|
|
119
|
+
return lines.join('\n') + '\n';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Serialize an object into YAML lines at the given indentation level.
|
|
124
|
+
* @param {Object} obj - Object to serialize
|
|
125
|
+
* @param {number} indent - Current indentation level (number of spaces)
|
|
126
|
+
* @param {string[]} lines - Output lines array
|
|
127
|
+
*/
|
|
128
|
+
function serializeObject(obj, indent, lines) {
|
|
129
|
+
const prefix = ' '.repeat(indent);
|
|
130
|
+
const keys = Object.keys(obj);
|
|
131
|
+
|
|
132
|
+
for (const key of keys) {
|
|
133
|
+
const value = obj[key];
|
|
134
|
+
serializeKeyValue(key, value, indent, prefix, lines);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Serialize a key-value pair into YAML lines.
|
|
140
|
+
* @param {string} key - Object key
|
|
141
|
+
* @param {*} value - Value to serialize
|
|
142
|
+
* @param {number} indent - Current indentation level
|
|
143
|
+
* @param {string} prefix - Indentation string
|
|
144
|
+
* @param {string[]} lines - Output lines array
|
|
145
|
+
*/
|
|
146
|
+
function serializeKeyValue(key, value, indent, prefix, lines) {
|
|
147
|
+
if (value === null || value === undefined) {
|
|
148
|
+
lines.push(`${prefix}${key}: null`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (typeof value !== 'object') {
|
|
153
|
+
// Scalar value
|
|
154
|
+
lines.push(`${prefix}${key}: ${formatYamlValue(value)}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
if (value.length === 0) {
|
|
160
|
+
lines.push(`${prefix}${key}: []`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
lines.push(`${prefix}${key}:`);
|
|
165
|
+
serializeArray(value, indent + 2, lines);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Nested object
|
|
170
|
+
if (Object.keys(value).length === 0) {
|
|
171
|
+
lines.push(`${prefix}${key}: {}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
lines.push(`${prefix}${key}:`);
|
|
176
|
+
serializeObject(value, indent + 2, lines);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Serialize an array into YAML lines.
|
|
181
|
+
* @param {Array} arr - Array to serialize
|
|
182
|
+
* @param {number} indent - Current indentation level
|
|
183
|
+
* @param {string[]} lines - Output lines array
|
|
184
|
+
*/
|
|
185
|
+
function serializeArray(arr, indent, lines) {
|
|
186
|
+
const prefix = ' '.repeat(indent);
|
|
187
|
+
|
|
188
|
+
for (const item of arr) {
|
|
189
|
+
if (item === null || item === undefined) {
|
|
190
|
+
lines.push(`${prefix}- null`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (typeof item !== 'object') {
|
|
195
|
+
// Scalar array item
|
|
196
|
+
lines.push(`${prefix}- ${formatYamlValue(item)}`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (Array.isArray(item)) {
|
|
201
|
+
// Nested array — serialize as block under "- "
|
|
202
|
+
lines.push(`${prefix}-`);
|
|
203
|
+
serializeArray(item, indent + 2, lines);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Object array item — first key on same line as "- ", rest indented
|
|
208
|
+
const keys = Object.keys(item);
|
|
209
|
+
|
|
210
|
+
if (keys.length === 0) {
|
|
211
|
+
lines.push(`${prefix}- {}`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const firstKey = keys[0];
|
|
216
|
+
const firstValue = item[firstKey];
|
|
217
|
+
|
|
218
|
+
if (firstValue !== null && firstValue !== undefined && typeof firstValue === 'object') {
|
|
219
|
+
// First value is complex — put key on "- " line, value below
|
|
220
|
+
if (Array.isArray(firstValue)) {
|
|
221
|
+
if (firstValue.length === 0) {
|
|
222
|
+
lines.push(`${prefix}- ${firstKey}: []`);
|
|
223
|
+
} else {
|
|
224
|
+
lines.push(`${prefix}- ${firstKey}:`);
|
|
225
|
+
serializeArray(firstValue, indent + 4, lines);
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
if (Object.keys(firstValue).length === 0) {
|
|
229
|
+
lines.push(`${prefix}- ${firstKey}: {}`);
|
|
230
|
+
} else {
|
|
231
|
+
lines.push(`${prefix}- ${firstKey}:`);
|
|
232
|
+
serializeObject(firstValue, indent + 4, lines);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
// First value is scalar — put on same line as "- "
|
|
237
|
+
lines.push(`${prefix}- ${firstKey}: ${formatYamlValue(firstValue)}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Remaining keys indented to align with first key
|
|
241
|
+
for (let i = 1; i < keys.length; i++) {
|
|
242
|
+
const k = keys[i];
|
|
243
|
+
const v = item[k];
|
|
244
|
+
serializeKeyValue(k, v, indent + 2, ' '.repeat(indent + 2), lines);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── YAML Parser ──────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Parse a YAML string back into an object.
|
|
253
|
+
* Handles the subset of YAML used by scene manifests:
|
|
254
|
+
* indentation-based nesting, "- " array items, key: value pairs,
|
|
255
|
+
* boolean (true/false), number, and string values.
|
|
256
|
+
* @param {string} yamlString - YAML content
|
|
257
|
+
* @returns {Object} Parsed object
|
|
258
|
+
*/
|
|
259
|
+
function parseYaml(yamlString) {
|
|
260
|
+
if (!yamlString || typeof yamlString !== 'string') {
|
|
261
|
+
return {};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const trimmed = yamlString.trim();
|
|
265
|
+
|
|
266
|
+
if (!trimmed) {
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (trimmed === 'null') {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Split into lines, preserving empty lines for structure
|
|
275
|
+
const rawLines = trimmed.split('\n');
|
|
276
|
+
|
|
277
|
+
// Filter out empty lines and comment lines, but keep track of indentation
|
|
278
|
+
const lines = [];
|
|
279
|
+
|
|
280
|
+
for (const raw of rawLines) {
|
|
281
|
+
// Skip empty lines and comment-only lines
|
|
282
|
+
if (raw.trim() === '' || raw.trim().startsWith('#')) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
lines.push(raw);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (lines.length === 0) {
|
|
290
|
+
return {};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Check if the first line is a scalar value (no colon, no dash)
|
|
294
|
+
const firstLine = lines[0].trim();
|
|
295
|
+
|
|
296
|
+
if (lines.length === 1 && !firstLine.includes(':') && !firstLine.startsWith('-')) {
|
|
297
|
+
return parseScalarValue(firstLine);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const result = parseBlock(lines, 0, lines.length, 0);
|
|
301
|
+
|
|
302
|
+
return result.value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Parse a block of YAML lines into a value (object or array).
|
|
307
|
+
* @param {string[]} lines - All lines
|
|
308
|
+
* @param {number} start - Start index (inclusive)
|
|
309
|
+
* @param {number} end - End index (exclusive)
|
|
310
|
+
* @param {number} expectedIndent - Expected indentation level
|
|
311
|
+
* @returns {{ value: Object|Array }}
|
|
312
|
+
*/
|
|
313
|
+
function parseBlock(lines, start, end, expectedIndent) {
|
|
314
|
+
if (start >= end) {
|
|
315
|
+
return { value: {} };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const firstLine = lines[start];
|
|
319
|
+
const firstContent = firstLine.trimStart();
|
|
320
|
+
|
|
321
|
+
// Determine if this block is an array or object
|
|
322
|
+
if (firstContent.startsWith('- ') || firstContent === '-') {
|
|
323
|
+
return { value: parseArrayBlock(lines, start, end, expectedIndent) };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { value: parseObjectBlock(lines, start, end, expectedIndent) };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Get the indentation level of a line (number of leading spaces).
|
|
331
|
+
* @param {string} line - Input line
|
|
332
|
+
* @returns {number} Number of leading spaces
|
|
333
|
+
*/
|
|
334
|
+
function getIndent(line) {
|
|
335
|
+
let count = 0;
|
|
336
|
+
|
|
337
|
+
for (let i = 0; i < line.length; i++) {
|
|
338
|
+
if (line[i] === ' ') {
|
|
339
|
+
count++;
|
|
340
|
+
} else {
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return count;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Parse a block of lines as an object.
|
|
350
|
+
* @param {string[]} lines - All lines
|
|
351
|
+
* @param {number} start - Start index
|
|
352
|
+
* @param {number} end - End index
|
|
353
|
+
* @param {number} baseIndent - Base indentation level
|
|
354
|
+
* @returns {Object}
|
|
355
|
+
*/
|
|
356
|
+
function parseObjectBlock(lines, start, end, baseIndent) {
|
|
357
|
+
const result = {};
|
|
358
|
+
let i = start;
|
|
359
|
+
|
|
360
|
+
while (i < end) {
|
|
361
|
+
const line = lines[i];
|
|
362
|
+
const indent = getIndent(line);
|
|
363
|
+
|
|
364
|
+
// Skip lines with deeper indentation (they belong to a previous key)
|
|
365
|
+
if (indent < baseIndent) {
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const content = line.trimStart();
|
|
370
|
+
|
|
371
|
+
// Skip if this is an array item at this level
|
|
372
|
+
if (content.startsWith('- ') || content === '-') {
|
|
373
|
+
i++;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const colonIdx = content.indexOf(':');
|
|
378
|
+
|
|
379
|
+
if (colonIdx === -1) {
|
|
380
|
+
i++;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const key = content.substring(0, colonIdx).trim();
|
|
385
|
+
const afterColon = content.substring(colonIdx + 1).trim();
|
|
386
|
+
|
|
387
|
+
if (afterColon === '' || afterColon === '') {
|
|
388
|
+
// Value is on subsequent lines (nested block)
|
|
389
|
+
const childIndent = findChildIndent(lines, i + 1, end);
|
|
390
|
+
|
|
391
|
+
if (childIndent > indent) {
|
|
392
|
+
const childEnd = findBlockEnd(lines, i + 1, end, childIndent);
|
|
393
|
+
const child = parseBlock(lines, i + 1, childEnd, childIndent);
|
|
394
|
+
result[key] = child.value;
|
|
395
|
+
i = childEnd;
|
|
396
|
+
} else {
|
|
397
|
+
// Empty value — treat as null
|
|
398
|
+
result[key] = null;
|
|
399
|
+
i++;
|
|
400
|
+
}
|
|
401
|
+
} else if (afterColon === '[]') {
|
|
402
|
+
result[key] = [];
|
|
403
|
+
i++;
|
|
404
|
+
} else if (afterColon === '{}') {
|
|
405
|
+
result[key] = {};
|
|
406
|
+
i++;
|
|
407
|
+
} else {
|
|
408
|
+
// Inline scalar value
|
|
409
|
+
result[key] = parseScalarValue(afterColon);
|
|
410
|
+
i++;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Parse a block of lines as an array.
|
|
419
|
+
* @param {string[]} lines - All lines
|
|
420
|
+
* @param {number} start - Start index
|
|
421
|
+
* @param {number} end - End index
|
|
422
|
+
* @param {number} baseIndent - Base indentation level
|
|
423
|
+
* @returns {Array}
|
|
424
|
+
*/
|
|
425
|
+
function parseArrayBlock(lines, start, end, baseIndent) {
|
|
426
|
+
const result = [];
|
|
427
|
+
let i = start;
|
|
428
|
+
|
|
429
|
+
while (i < end) {
|
|
430
|
+
const line = lines[i];
|
|
431
|
+
const indent = getIndent(line);
|
|
432
|
+
|
|
433
|
+
if (indent < baseIndent) {
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const content = line.trimStart();
|
|
438
|
+
|
|
439
|
+
if (!content.startsWith('- ') && content !== '-') {
|
|
440
|
+
i++;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (content === '-') {
|
|
445
|
+
// Bare dash — value is on subsequent lines
|
|
446
|
+
const childIndent = findChildIndent(lines, i + 1, end);
|
|
447
|
+
|
|
448
|
+
if (childIndent > indent) {
|
|
449
|
+
const childEnd = findBlockEnd(lines, i + 1, end, childIndent);
|
|
450
|
+
const child = parseBlock(lines, i + 1, childEnd, childIndent);
|
|
451
|
+
result.push(child.value);
|
|
452
|
+
i = childEnd;
|
|
453
|
+
} else {
|
|
454
|
+
result.push(null);
|
|
455
|
+
i++;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// "- " prefix — extract the content after "- "
|
|
462
|
+
const itemContent = content.substring(2);
|
|
463
|
+
|
|
464
|
+
if (itemContent === '[]') {
|
|
465
|
+
result.push([]);
|
|
466
|
+
i++;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (itemContent === '{}') {
|
|
471
|
+
result.push({});
|
|
472
|
+
i++;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Check if item content has a colon (it's an object entry)
|
|
477
|
+
const colonIdx = itemContent.indexOf(':');
|
|
478
|
+
|
|
479
|
+
if (colonIdx !== -1) {
|
|
480
|
+
// This is an object item in the array
|
|
481
|
+
// Parse the first key-value, then look for more keys at indent+2
|
|
482
|
+
const firstKey = itemContent.substring(0, colonIdx).trim();
|
|
483
|
+
const afterColon = itemContent.substring(colonIdx + 1).trim();
|
|
484
|
+
|
|
485
|
+
const obj = {};
|
|
486
|
+
|
|
487
|
+
if (afterColon === '') {
|
|
488
|
+
// Value is on subsequent lines
|
|
489
|
+
const valueIndent = findChildIndent(lines, i + 1, end);
|
|
490
|
+
|
|
491
|
+
if (valueIndent > indent + 2) {
|
|
492
|
+
const valueEnd = findBlockEnd(lines, i + 1, end, valueIndent);
|
|
493
|
+
const child = parseBlock(lines, i + 1, valueEnd, valueIndent);
|
|
494
|
+
obj[firstKey] = child.value;
|
|
495
|
+
i = valueEnd;
|
|
496
|
+
} else if (valueIndent === indent + 2) {
|
|
497
|
+
// Could be the value block or sibling keys
|
|
498
|
+
// Check if next line is a key at indent+2 or deeper content
|
|
499
|
+
const nextContent = lines[i + 1] ? lines[i + 1].trimStart() : '';
|
|
500
|
+
const nextIndent = lines[i + 1] ? getIndent(lines[i + 1]) : 0;
|
|
501
|
+
|
|
502
|
+
if (nextIndent > indent + 2) {
|
|
503
|
+
// Deeper content — it's the value
|
|
504
|
+
const valueEnd = findBlockEnd(lines, i + 1, end, nextIndent);
|
|
505
|
+
const child = parseBlock(lines, i + 1, valueEnd, nextIndent);
|
|
506
|
+
obj[firstKey] = child.value;
|
|
507
|
+
i = valueEnd;
|
|
508
|
+
} else {
|
|
509
|
+
// Same level as sibling keys — value is null, parse siblings
|
|
510
|
+
obj[firstKey] = null;
|
|
511
|
+
i++;
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
obj[firstKey] = null;
|
|
515
|
+
i++;
|
|
516
|
+
}
|
|
517
|
+
} else if (afterColon === '[]') {
|
|
518
|
+
obj[firstKey] = [];
|
|
519
|
+
i++;
|
|
520
|
+
} else if (afterColon === '{}') {
|
|
521
|
+
obj[firstKey] = {};
|
|
522
|
+
i++;
|
|
523
|
+
} else {
|
|
524
|
+
obj[firstKey] = parseScalarValue(afterColon);
|
|
525
|
+
i++;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Parse remaining keys at indent+2
|
|
529
|
+
const siblingIndent = indent + 2;
|
|
530
|
+
|
|
531
|
+
while (i < end) {
|
|
532
|
+
const sibLine = lines[i];
|
|
533
|
+
const sibIndent = getIndent(sibLine);
|
|
534
|
+
|
|
535
|
+
if (sibIndent < siblingIndent) {
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (sibIndent > siblingIndent) {
|
|
540
|
+
// This belongs to a previous key's value — skip
|
|
541
|
+
i++;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const sibContent = sibLine.trimStart();
|
|
546
|
+
|
|
547
|
+
// If it's a new array item at the base indent, stop
|
|
548
|
+
if (sibContent.startsWith('- ') && sibIndent === baseIndent) {
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const sibColonIdx = sibContent.indexOf(':');
|
|
553
|
+
|
|
554
|
+
if (sibColonIdx === -1) {
|
|
555
|
+
i++;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const sibKey = sibContent.substring(0, sibColonIdx).trim();
|
|
560
|
+
const sibAfterColon = sibContent.substring(sibColonIdx + 1).trim();
|
|
561
|
+
|
|
562
|
+
if (sibAfterColon === '') {
|
|
563
|
+
const childIndent = findChildIndent(lines, i + 1, end);
|
|
564
|
+
|
|
565
|
+
if (childIndent > siblingIndent) {
|
|
566
|
+
const childEnd = findBlockEnd(lines, i + 1, end, childIndent);
|
|
567
|
+
const child = parseBlock(lines, i + 1, childEnd, childIndent);
|
|
568
|
+
obj[sibKey] = child.value;
|
|
569
|
+
i = childEnd;
|
|
570
|
+
} else {
|
|
571
|
+
obj[sibKey] = null;
|
|
572
|
+
i++;
|
|
573
|
+
}
|
|
574
|
+
} else if (sibAfterColon === '[]') {
|
|
575
|
+
obj[sibKey] = [];
|
|
576
|
+
i++;
|
|
577
|
+
} else if (sibAfterColon === '{}') {
|
|
578
|
+
obj[sibKey] = {};
|
|
579
|
+
i++;
|
|
580
|
+
} else {
|
|
581
|
+
obj[sibKey] = parseScalarValue(sibAfterColon);
|
|
582
|
+
i++;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
result.push(obj);
|
|
587
|
+
} else {
|
|
588
|
+
// Scalar array item
|
|
589
|
+
result.push(parseScalarValue(itemContent));
|
|
590
|
+
i++;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return result;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Find the indentation level of the first non-empty child line.
|
|
599
|
+
* @param {string[]} lines - All lines
|
|
600
|
+
* @param {number} start - Start index
|
|
601
|
+
* @param {number} end - End index
|
|
602
|
+
* @returns {number} Child indentation level, or -1 if no child found
|
|
603
|
+
*/
|
|
604
|
+
function findChildIndent(lines, start, end) {
|
|
605
|
+
for (let i = start; i < end; i++) {
|
|
606
|
+
const line = lines[i];
|
|
607
|
+
const trimmed = line.trim();
|
|
608
|
+
|
|
609
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return getIndent(line);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return -1;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Find the end index of a block at the given indentation level.
|
|
621
|
+
* @param {string[]} lines - All lines
|
|
622
|
+
* @param {number} start - Start index
|
|
623
|
+
* @param {number} end - End index
|
|
624
|
+
* @param {number} blockIndent - Block indentation level
|
|
625
|
+
* @returns {number} End index (exclusive)
|
|
626
|
+
*/
|
|
627
|
+
function findBlockEnd(lines, start, end, blockIndent) {
|
|
628
|
+
for (let i = start; i < end; i++) {
|
|
629
|
+
const line = lines[i];
|
|
630
|
+
const trimmed = line.trim();
|
|
631
|
+
|
|
632
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const indent = getIndent(line);
|
|
637
|
+
|
|
638
|
+
if (indent < blockIndent) {
|
|
639
|
+
return i;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return end;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Parse a scalar YAML value string into a JavaScript value.
|
|
648
|
+
* Handles: booleans, numbers, null, quoted strings, unquoted strings.
|
|
649
|
+
* @param {string} value - Raw value string
|
|
650
|
+
* @returns {*} Parsed value
|
|
651
|
+
*/
|
|
652
|
+
function parseScalarValue(value) {
|
|
653
|
+
if (value === 'null' || value === 'Null' || value === 'NULL' || value === '~') {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (value === 'true' || value === 'True' || value === 'TRUE') {
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (value === 'false' || value === 'False' || value === 'FALSE') {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Quoted string (double quotes)
|
|
666
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
667
|
+
const inner = value.slice(1, -1);
|
|
668
|
+
return inner
|
|
669
|
+
.replace(/\\n/g, '\n')
|
|
670
|
+
.replace(/\\r/g, '\r')
|
|
671
|
+
.replace(/\\t/g, '\t')
|
|
672
|
+
.replace(/\\"/g, '"')
|
|
673
|
+
.replace(/\\\\/g, '\\');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Quoted string (single quotes)
|
|
677
|
+
if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
|
|
678
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Number
|
|
682
|
+
if (/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(value)) {
|
|
683
|
+
const num = Number(value);
|
|
684
|
+
|
|
685
|
+
if (!isNaN(num) && isFinite(num)) {
|
|
686
|
+
return num;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return value;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ─── PascalCase to kebab-case ──────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Convert a PascalCase or camelCase string to kebab-case.
|
|
697
|
+
* E.g., "OrderHeader" → "order-header", "Order" → "order",
|
|
698
|
+
* "HTMLParser" → "html-parser", "myValue" → "my-value"
|
|
699
|
+
* @param {string} str - PascalCase/camelCase string
|
|
700
|
+
* @returns {string} kebab-case string
|
|
701
|
+
*/
|
|
702
|
+
function toKebabCase(str) {
|
|
703
|
+
if (!str || typeof str !== 'string') {
|
|
704
|
+
return '';
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Insert hyphen before uppercase letters that follow lowercase letters or
|
|
708
|
+
// before an uppercase letter followed by a lowercase letter in a run of uppercase
|
|
709
|
+
return str
|
|
710
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
711
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
712
|
+
.toLowerCase();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ─── Entity Grouping ──────────────────────────────────────────────
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Group related entities by header/item suffix patterns.
|
|
719
|
+
* E.g., OrderHeader + OrderItem → { base: 'Order', entities: ['OrderHeader', 'OrderItem'], isComposite: true }
|
|
720
|
+
* Entities without any suffix match are placed in their own group with isComposite: false.
|
|
721
|
+
* Every input entity appears in exactly one group.
|
|
722
|
+
* @param {string[]} entityNames - List of entity names
|
|
723
|
+
* @returns {EntityGroup[]}
|
|
724
|
+
*/
|
|
725
|
+
function groupRelatedEntities(entityNames) {
|
|
726
|
+
if (!Array.isArray(entityNames) || entityNames.length === 0) {
|
|
727
|
+
return [];
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Track which entities have been assigned to a group
|
|
731
|
+
const assigned = new Set();
|
|
732
|
+
const groups = [];
|
|
733
|
+
|
|
734
|
+
// First pass: find header/item pairs using HEADER_ITEM_SUFFIXES
|
|
735
|
+
for (const suffixPair of HEADER_ITEM_SUFFIXES) {
|
|
736
|
+
// Find all entities ending with the header suffix
|
|
737
|
+
for (const entity of entityNames) {
|
|
738
|
+
if (assigned.has(entity)) {
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (!entity.endsWith(suffixPair.header)) {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const base = entity.substring(0, entity.length - suffixPair.header.length);
|
|
747
|
+
|
|
748
|
+
if (!base) {
|
|
749
|
+
continue; // Skip if the entity name IS the suffix (e.g., "Header")
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Look for matching item entity
|
|
753
|
+
const itemEntity = base + suffixPair.item;
|
|
754
|
+
|
|
755
|
+
if (entityNames.includes(itemEntity) && !assigned.has(itemEntity)) {
|
|
756
|
+
// Found a header/item pair
|
|
757
|
+
const groupEntities = [entity, itemEntity];
|
|
758
|
+
assigned.add(entity);
|
|
759
|
+
assigned.add(itemEntity);
|
|
760
|
+
|
|
761
|
+
groups.push({
|
|
762
|
+
base,
|
|
763
|
+
entities: groupEntities,
|
|
764
|
+
isComposite: true
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Second pass: assign remaining entities to their own groups
|
|
771
|
+
for (const entity of entityNames) {
|
|
772
|
+
if (assigned.has(entity)) {
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
assigned.add(entity);
|
|
777
|
+
groups.push({
|
|
778
|
+
base: entity,
|
|
779
|
+
entities: [entity],
|
|
780
|
+
isComposite: false
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return groups;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ─── Bundle and Package Name Derivation ───────────────────────────
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Derive a bundle directory name from pattern and resource.
|
|
791
|
+
* Returns kebab-case string like "crud-order".
|
|
792
|
+
* @param {PatternMatch} match - Pattern match
|
|
793
|
+
* @returns {string} Directory name in kebab-case
|
|
794
|
+
*/
|
|
795
|
+
function deriveBundleDirName(match) {
|
|
796
|
+
if (!match || !match.pattern || !match.primaryResource) {
|
|
797
|
+
return '';
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const pattern = match.pattern.toLowerCase();
|
|
801
|
+
const resource = toKebabCase(match.primaryResource);
|
|
802
|
+
|
|
803
|
+
return `${pattern}-${resource}`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Derive a package name from pattern and resource.
|
|
808
|
+
* Returns kebab-case string like "crud-order".
|
|
809
|
+
* @param {PatternMatch} match - Pattern match
|
|
810
|
+
* @returns {string} Package name in kebab-case
|
|
811
|
+
*/
|
|
812
|
+
function derivePackageName(match) {
|
|
813
|
+
if (!match || !match.pattern || !match.primaryResource) {
|
|
814
|
+
return '';
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const pattern = match.pattern.toLowerCase();
|
|
818
|
+
const resource = toKebabCase(match.primaryResource);
|
|
819
|
+
|
|
820
|
+
return `${pattern}-${resource}`;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ─── Pattern Matching ─────────────────────────────────────────────
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Derive the idempotency key from an entity name.
|
|
827
|
+
* Converts the entity name to a camelCase ID field.
|
|
828
|
+
* E.g., "OrderHeader" → "orderId", "Product" → "productId"
|
|
829
|
+
* @param {string} entityName - Entity name (PascalCase)
|
|
830
|
+
* @returns {string} Idempotency key
|
|
831
|
+
*/
|
|
832
|
+
function deriveIdempotencyKey(entityName) {
|
|
833
|
+
if (!entityName || typeof entityName !== 'string') {
|
|
834
|
+
return '';
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Use the base name (strip common suffixes like Header, Item, Detail, Master)
|
|
838
|
+
let base = entityName;
|
|
839
|
+
|
|
840
|
+
for (const suffixPair of HEADER_ITEM_SUFFIXES) {
|
|
841
|
+
if (base.endsWith(suffixPair.header)) {
|
|
842
|
+
base = base.substring(0, base.length - suffixPair.header.length);
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (base.endsWith(suffixPair.item)) {
|
|
847
|
+
base = base.substring(0, base.length - suffixPair.item.length);
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// If stripping left nothing, use original
|
|
853
|
+
if (!base) {
|
|
854
|
+
base = entityName;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Convert to camelCase + "Id"
|
|
858
|
+
const camel = base.charAt(0).toLowerCase() + base.slice(1);
|
|
859
|
+
|
|
860
|
+
return camel + 'Id';
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Generate model scope entries for an entity.
|
|
865
|
+
* Read scope includes entityId and statusId fields.
|
|
866
|
+
* Write scope includes statusId field (for crud patterns).
|
|
867
|
+
* @param {string} primaryEntity - Primary entity name
|
|
868
|
+
* @param {string} pattern - Pattern type ('crud' | 'query')
|
|
869
|
+
* @returns {{ read: string[], write: string[] }}
|
|
870
|
+
*/
|
|
871
|
+
function generateEntityModelScope(primaryEntity, pattern) {
|
|
872
|
+
const idKey = deriveIdempotencyKey(primaryEntity);
|
|
873
|
+
const read = [
|
|
874
|
+
`moqui.${primaryEntity}.${idKey}`,
|
|
875
|
+
`moqui.${primaryEntity}.statusId`
|
|
876
|
+
];
|
|
877
|
+
|
|
878
|
+
const write = pattern === 'crud'
|
|
879
|
+
? [`moqui.${primaryEntity}.statusId`]
|
|
880
|
+
: [];
|
|
881
|
+
|
|
882
|
+
return { read, write };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Match an entity group against pattern rules.
|
|
887
|
+
* If the entity group has related services (services containing the entity base name),
|
|
888
|
+
* classify as "crud". Otherwise, classify as "query" (read-only).
|
|
889
|
+
*
|
|
890
|
+
* @param {EntityGroup} group - Grouped entity info { base, entities, isComposite }
|
|
891
|
+
* @param {string[]} services - Available service names
|
|
892
|
+
* @returns {PatternMatch|null}
|
|
893
|
+
*/
|
|
894
|
+
function matchEntityPattern(group, services) {
|
|
895
|
+
if (!group || !group.base || !Array.isArray(group.entities) || group.entities.length === 0) {
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
services = Array.isArray(services) ? services : [];
|
|
900
|
+
|
|
901
|
+
// Determine the primary entity (first entity in the group, typically the header entity)
|
|
902
|
+
const primaryEntity = group.entities[0];
|
|
903
|
+
|
|
904
|
+
// Check if any service name contains the base entity name (case-insensitive)
|
|
905
|
+
const baseLower = group.base.toLowerCase();
|
|
906
|
+
const hasRelatedServices = services.some(svc => {
|
|
907
|
+
if (!svc || typeof svc !== 'string') {
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return svc.toLowerCase().includes(baseLower);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
if (hasRelatedServices) {
|
|
915
|
+
// CRUD pattern: entity with related services → all 5 operations
|
|
916
|
+
const bindingRefs = [
|
|
917
|
+
`moqui.${primaryEntity}.list`,
|
|
918
|
+
`moqui.${primaryEntity}.get`,
|
|
919
|
+
`moqui.${primaryEntity}.create`,
|
|
920
|
+
`moqui.${primaryEntity}.update`,
|
|
921
|
+
`moqui.${primaryEntity}.delete`
|
|
922
|
+
];
|
|
923
|
+
|
|
924
|
+
const modelScope = generateEntityModelScope(primaryEntity, 'crud');
|
|
925
|
+
const idempotencyKey = deriveIdempotencyKey(primaryEntity);
|
|
926
|
+
|
|
927
|
+
return {
|
|
928
|
+
pattern: 'crud',
|
|
929
|
+
primaryResource: group.base,
|
|
930
|
+
entities: [...group.entities],
|
|
931
|
+
services: [],
|
|
932
|
+
bindingRefs,
|
|
933
|
+
modelScope,
|
|
934
|
+
governance: {
|
|
935
|
+
riskLevel: 'medium',
|
|
936
|
+
approvalRequired: true,
|
|
937
|
+
idempotencyRequired: true,
|
|
938
|
+
idempotencyKey
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Query pattern: entity without related services → read-only (list + get)
|
|
944
|
+
const bindingRefs = [
|
|
945
|
+
`moqui.${primaryEntity}.list`,
|
|
946
|
+
`moqui.${primaryEntity}.get`
|
|
947
|
+
];
|
|
948
|
+
|
|
949
|
+
const modelScope = generateEntityModelScope(primaryEntity, 'query');
|
|
950
|
+
|
|
951
|
+
return {
|
|
952
|
+
pattern: 'query',
|
|
953
|
+
primaryResource: group.base,
|
|
954
|
+
entities: [...group.entities],
|
|
955
|
+
services: [],
|
|
956
|
+
bindingRefs,
|
|
957
|
+
modelScope,
|
|
958
|
+
governance: {
|
|
959
|
+
riskLevel: 'low',
|
|
960
|
+
approvalRequired: false,
|
|
961
|
+
idempotencyRequired: false
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Match services against workflow pattern rules.
|
|
968
|
+
* Services that don't directly map to entity CRUD operations are workflow candidates.
|
|
969
|
+
* Groups related services into workflow patterns.
|
|
970
|
+
*
|
|
971
|
+
* @param {string[]} services - Service names
|
|
972
|
+
* @param {string[]} entities - Entity names
|
|
973
|
+
* @returns {PatternMatch[]}
|
|
974
|
+
*/
|
|
975
|
+
function matchWorkflowPatterns(services, entities) {
|
|
976
|
+
if (!Array.isArray(services) || services.length === 0) {
|
|
977
|
+
return [];
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
entities = Array.isArray(entities) ? entities : [];
|
|
981
|
+
|
|
982
|
+
// Build a set of entity base names (lowercase) for matching
|
|
983
|
+
const entityBaseNames = new Set();
|
|
984
|
+
|
|
985
|
+
for (const entity of entities) {
|
|
986
|
+
if (!entity || typeof entity !== 'string') {
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
entityBaseNames.add(entity.toLowerCase());
|
|
991
|
+
|
|
992
|
+
// Also add stripped base names (without Header/Item/Detail/Master suffixes)
|
|
993
|
+
for (const suffixPair of HEADER_ITEM_SUFFIXES) {
|
|
994
|
+
if (entity.endsWith(suffixPair.header)) {
|
|
995
|
+
const base = entity.substring(0, entity.length - suffixPair.header.length);
|
|
996
|
+
|
|
997
|
+
if (base) {
|
|
998
|
+
entityBaseNames.add(base.toLowerCase());
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (entity.endsWith(suffixPair.item)) {
|
|
1003
|
+
const base = entity.substring(0, entity.length - suffixPair.item.length);
|
|
1004
|
+
|
|
1005
|
+
if (base) {
|
|
1006
|
+
entityBaseNames.add(base.toLowerCase());
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Filter services that are NOT direct entity CRUD operations
|
|
1013
|
+
// A service is a CRUD operation if its name matches an entity base name
|
|
1014
|
+
const workflowServices = services.filter(svc => {
|
|
1015
|
+
if (!svc || typeof svc !== 'string') {
|
|
1016
|
+
return false;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const svcLower = svc.toLowerCase();
|
|
1020
|
+
|
|
1021
|
+
// Check if the service name directly matches any entity base name
|
|
1022
|
+
for (const baseName of entityBaseNames) {
|
|
1023
|
+
if (svcLower === baseName || svcLower.includes(baseName)) {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
return true;
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
if (workflowServices.length === 0) {
|
|
1032
|
+
return [];
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Find entities referenced by workflow services
|
|
1036
|
+
// (entities whose base name appears in any workflow service name)
|
|
1037
|
+
const referencedEntities = entities.filter(entity => {
|
|
1038
|
+
if (!entity || typeof entity !== 'string') {
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const entityLower = entity.toLowerCase();
|
|
1043
|
+
|
|
1044
|
+
return workflowServices.some(svc => svc.toLowerCase().includes(entityLower));
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// Generate binding refs for each workflow service
|
|
1048
|
+
const bindingRefs = workflowServices.map(svc => `moqui.service.${svc}.invoke`);
|
|
1049
|
+
|
|
1050
|
+
// Generate model scope from referenced entities
|
|
1051
|
+
const read = [];
|
|
1052
|
+
const write = [];
|
|
1053
|
+
|
|
1054
|
+
for (const entity of referencedEntities) {
|
|
1055
|
+
const idKey = deriveIdempotencyKey(entity);
|
|
1056
|
+
read.push(`moqui.${entity}.${idKey}`);
|
|
1057
|
+
read.push(`moqui.${entity}.statusId`);
|
|
1058
|
+
write.push(`moqui.${entity}.statusId`);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Create a single workflow pattern match for all workflow services
|
|
1062
|
+
const primaryResource = workflowServices[0];
|
|
1063
|
+
|
|
1064
|
+
return [{
|
|
1065
|
+
pattern: 'workflow',
|
|
1066
|
+
primaryResource,
|
|
1067
|
+
entities: referencedEntities.length > 0 ? [...referencedEntities] : [],
|
|
1068
|
+
services: [...workflowServices],
|
|
1069
|
+
bindingRefs,
|
|
1070
|
+
modelScope: { read, write },
|
|
1071
|
+
governance: {
|
|
1072
|
+
riskLevel: 'medium',
|
|
1073
|
+
approvalRequired: true,
|
|
1074
|
+
idempotencyRequired: true
|
|
1075
|
+
}
|
|
1076
|
+
}];
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ─── Resource Analysis (Orchestrator) ─────────────────────────
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Analyze discovered resources and identify business patterns.
|
|
1083
|
+
* Orchestrates pattern matching across all discovered resources,
|
|
1084
|
+
* applies optional --pattern filter, and handles the empty-match case.
|
|
1085
|
+
*
|
|
1086
|
+
* @param {DiscoveryPayload} discovery - Discovered resources { entities, services, screens }
|
|
1087
|
+
* @param {Object} options - { pattern?: string } — optional pattern filter ('crud' | 'query' | 'workflow')
|
|
1088
|
+
* @returns {PatternMatch[]}
|
|
1089
|
+
*/
|
|
1090
|
+
function analyzeResources(discovery, options = {}) {
|
|
1091
|
+
// Edge case: null/undefined/empty discovery
|
|
1092
|
+
if (!discovery) {
|
|
1093
|
+
return [];
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const entities = Array.isArray(discovery.entities) ? discovery.entities : [];
|
|
1097
|
+
const services = Array.isArray(discovery.services) ? discovery.services : [];
|
|
1098
|
+
|
|
1099
|
+
// If no entities and no services, nothing to analyze
|
|
1100
|
+
if (entities.length === 0 && services.length === 0) {
|
|
1101
|
+
return [];
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const results = [];
|
|
1105
|
+
|
|
1106
|
+
// Step 1: Group related entities
|
|
1107
|
+
const groups = groupRelatedEntities(entities);
|
|
1108
|
+
|
|
1109
|
+
// Step 2: For each entity group, match against entity patterns (crud/query)
|
|
1110
|
+
for (const group of groups) {
|
|
1111
|
+
const match = matchEntityPattern(group, services);
|
|
1112
|
+
|
|
1113
|
+
if (match) {
|
|
1114
|
+
results.push(match);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Step 3: Detect workflow patterns from services
|
|
1119
|
+
const workflowMatches = matchWorkflowPatterns(services, entities);
|
|
1120
|
+
|
|
1121
|
+
for (const wm of workflowMatches) {
|
|
1122
|
+
results.push(wm);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Step 4: Apply --pattern filter if provided
|
|
1126
|
+
if (options.pattern) {
|
|
1127
|
+
const filtered = results.filter(m => m.pattern === options.pattern);
|
|
1128
|
+
return filtered;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
return results;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function buildBaseBindings(match) {
|
|
1135
|
+
const pattern = match.pattern;
|
|
1136
|
+
const bindings = [];
|
|
1137
|
+
|
|
1138
|
+
if (pattern === 'crud' || pattern === 'query') {
|
|
1139
|
+
const primaryEntity = Array.isArray(match.entities) && match.entities.length > 0
|
|
1140
|
+
? match.entities[0]
|
|
1141
|
+
: match.primaryResource;
|
|
1142
|
+
|
|
1143
|
+
bindings.push({
|
|
1144
|
+
type: 'query',
|
|
1145
|
+
ref: `moqui.${primaryEntity}.list`,
|
|
1146
|
+
timeout_ms: 2000,
|
|
1147
|
+
retry: 0
|
|
1148
|
+
});
|
|
1149
|
+
bindings.push({
|
|
1150
|
+
type: 'query',
|
|
1151
|
+
ref: `moqui.${primaryEntity}.get`,
|
|
1152
|
+
timeout_ms: 2000,
|
|
1153
|
+
retry: 0
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
if (pattern === 'crud') {
|
|
1157
|
+
bindings.push({
|
|
1158
|
+
type: 'mutation',
|
|
1159
|
+
ref: `moqui.${primaryEntity}.create`,
|
|
1160
|
+
side_effect: true,
|
|
1161
|
+
timeout_ms: 3000,
|
|
1162
|
+
retry: 0
|
|
1163
|
+
});
|
|
1164
|
+
bindings.push({
|
|
1165
|
+
type: 'mutation',
|
|
1166
|
+
ref: `moqui.${primaryEntity}.update`,
|
|
1167
|
+
side_effect: true,
|
|
1168
|
+
timeout_ms: 3000,
|
|
1169
|
+
retry: 0
|
|
1170
|
+
});
|
|
1171
|
+
bindings.push({
|
|
1172
|
+
type: 'mutation',
|
|
1173
|
+
ref: `moqui.${primaryEntity}.delete`,
|
|
1174
|
+
side_effect: true,
|
|
1175
|
+
timeout_ms: 3000,
|
|
1176
|
+
retry: 0
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
} else if (pattern === 'workflow') {
|
|
1180
|
+
const refs = Array.isArray(match.bindingRefs) ? match.bindingRefs : [];
|
|
1181
|
+
for (const ref of refs) {
|
|
1182
|
+
bindings.push({
|
|
1183
|
+
type: 'invoke',
|
|
1184
|
+
ref,
|
|
1185
|
+
timeout_ms: 3000,
|
|
1186
|
+
retry: 0
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
return bindings;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function deriveBindingIntent(binding, primaryResource) {
|
|
1195
|
+
const ref = String(binding.ref || '');
|
|
1196
|
+
|
|
1197
|
+
if (ref.endsWith('.list')) {
|
|
1198
|
+
return `List ${primaryResource} records from Moqui`;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (ref.endsWith('.get')) {
|
|
1202
|
+
return `Retrieve a single ${primaryResource} record`;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (ref.endsWith('.create')) {
|
|
1206
|
+
return `Create a new ${primaryResource} record`;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (ref.endsWith('.update')) {
|
|
1210
|
+
return `Update an existing ${primaryResource} record`;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (ref.endsWith('.delete')) {
|
|
1214
|
+
return `Delete an existing ${primaryResource} record`;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (ref.endsWith('.invoke')) {
|
|
1218
|
+
return `Invoke workflow service for ${primaryResource}`;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
return `Execute ${ref}`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function deriveBindingPreconditions(binding, previousRef) {
|
|
1225
|
+
const checks = ['Moqui adapter authentication is valid'];
|
|
1226
|
+
|
|
1227
|
+
if (binding.type === 'query') {
|
|
1228
|
+
checks.push('Read scope permits this query');
|
|
1229
|
+
} else if (binding.type === 'mutation') {
|
|
1230
|
+
checks.push('Input payload validation passed');
|
|
1231
|
+
} else if (binding.type === 'invoke') {
|
|
1232
|
+
checks.push('Workflow input contract is satisfied');
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if (previousRef) {
|
|
1236
|
+
checks.push(`Dependency ${previousRef} completed successfully`);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
return checks;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function deriveBindingPostconditions(binding) {
|
|
1243
|
+
if (binding.type === 'query') {
|
|
1244
|
+
return ['Query result is available for downstream composition'];
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (binding.type === 'mutation') {
|
|
1248
|
+
return ['Mutation result is captured and write scope is consistent'];
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (binding.type === 'invoke') {
|
|
1252
|
+
return ['Workflow step output is captured for downstream execution'];
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
return ['Binding execution result is available'];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function addBindingSemantics(baseBindings, primaryResource) {
|
|
1259
|
+
const bindings = [];
|
|
1260
|
+
|
|
1261
|
+
for (let i = 0; i < baseBindings.length; i++) {
|
|
1262
|
+
const base = baseBindings[i];
|
|
1263
|
+
const previous = i > 0 ? baseBindings[i - 1] : null;
|
|
1264
|
+
const binding = {
|
|
1265
|
+
...base,
|
|
1266
|
+
intent: deriveBindingIntent(base, primaryResource),
|
|
1267
|
+
preconditions: deriveBindingPreconditions(base, previous ? previous.ref : null),
|
|
1268
|
+
postconditions: deriveBindingPostconditions(base)
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
if (previous && previous.ref) {
|
|
1272
|
+
binding.depends_on = previous.ref;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
bindings.push(binding);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
return bindings;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function buildDataLineage(bindings, pattern, primaryResource) {
|
|
1282
|
+
if (!Array.isArray(bindings) || bindings.length === 0) {
|
|
1283
|
+
return {
|
|
1284
|
+
sources: [],
|
|
1285
|
+
transforms: [],
|
|
1286
|
+
sinks: []
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const firstRef = bindings[0].ref;
|
|
1291
|
+
const lastRef = bindings[bindings.length - 1].ref;
|
|
1292
|
+
const sourceField = `${toKebabCase(primaryResource)}Id`;
|
|
1293
|
+
|
|
1294
|
+
const transforms = [
|
|
1295
|
+
{
|
|
1296
|
+
operation: 'normalizeInput',
|
|
1297
|
+
description: `Normalize ${primaryResource} request payload for template execution`
|
|
1298
|
+
}
|
|
1299
|
+
];
|
|
1300
|
+
|
|
1301
|
+
if (pattern === 'workflow') {
|
|
1302
|
+
transforms.push({
|
|
1303
|
+
operation: 'orchestrateWorkflow',
|
|
1304
|
+
description: `Coordinate service chain for ${primaryResource}`
|
|
1305
|
+
});
|
|
1306
|
+
} else if (pattern === 'crud') {
|
|
1307
|
+
transforms.push({
|
|
1308
|
+
operation: 'applyMutationGuard',
|
|
1309
|
+
description: `Apply mutation and idempotency guard for ${primaryResource}`
|
|
1310
|
+
});
|
|
1311
|
+
} else {
|
|
1312
|
+
transforms.push({
|
|
1313
|
+
operation: 'projectQueryResult',
|
|
1314
|
+
description: `Project query result set for ${primaryResource}`
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
return {
|
|
1319
|
+
sources: [
|
|
1320
|
+
{
|
|
1321
|
+
ref: firstRef,
|
|
1322
|
+
fields: [sourceField, 'statusId']
|
|
1323
|
+
}
|
|
1324
|
+
],
|
|
1325
|
+
transforms,
|
|
1326
|
+
sinks: [
|
|
1327
|
+
{
|
|
1328
|
+
ref: lastRef,
|
|
1329
|
+
fields: [sourceField, 'statusId']
|
|
1330
|
+
}
|
|
1331
|
+
]
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function buildEntityRefs(match, primaryResource) {
|
|
1336
|
+
const refs = Array.isArray(match.entities) && match.entities.length > 0
|
|
1337
|
+
? match.entities.filter(Boolean)
|
|
1338
|
+
: [primaryResource];
|
|
1339
|
+
|
|
1340
|
+
return refs.map((entity, index) => ({
|
|
1341
|
+
id: String(entity),
|
|
1342
|
+
type: index === 0 ? 'primary' : 'related'
|
|
1343
|
+
}));
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function buildEntityRelations(entityRefs) {
|
|
1347
|
+
if (!Array.isArray(entityRefs) || entityRefs.length === 0) {
|
|
1348
|
+
return [];
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const relations = [];
|
|
1352
|
+
const primaryId = entityRefs[0].id;
|
|
1353
|
+
|
|
1354
|
+
for (let i = 1; i < entityRefs.length; i++) {
|
|
1355
|
+
relations.push({
|
|
1356
|
+
source: primaryId,
|
|
1357
|
+
target: entityRefs[i].id,
|
|
1358
|
+
type: 'composes'
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (relations.length === 0) {
|
|
1363
|
+
relations.push({
|
|
1364
|
+
source: primaryId,
|
|
1365
|
+
target: 'metadata_view',
|
|
1366
|
+
type: 'produces'
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return relations;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function buildBusinessRules(pattern, bindings, primaryResource) {
|
|
1374
|
+
const firstRef = bindings[0] ? bindings[0].ref : null;
|
|
1375
|
+
const lastRef = bindings[bindings.length - 1] ? bindings[bindings.length - 1].ref : null;
|
|
1376
|
+
|
|
1377
|
+
const rules = [
|
|
1378
|
+
{
|
|
1379
|
+
id: `rule.${toKebabCase(primaryResource)}.binding-order`,
|
|
1380
|
+
description: `Bindings for ${primaryResource} must execute in declared dependency order`,
|
|
1381
|
+
bind_to: firstRef,
|
|
1382
|
+
status: 'enforced'
|
|
1383
|
+
}
|
|
1384
|
+
];
|
|
1385
|
+
|
|
1386
|
+
if (pattern === 'query') {
|
|
1387
|
+
rules.push({
|
|
1388
|
+
id: `rule.${toKebabCase(primaryResource)}.read-only`,
|
|
1389
|
+
description: `${primaryResource} query template must remain side-effect free`,
|
|
1390
|
+
bind_to: lastRef,
|
|
1391
|
+
status: 'active'
|
|
1392
|
+
});
|
|
1393
|
+
} else {
|
|
1394
|
+
rules.push({
|
|
1395
|
+
id: `rule.${toKebabCase(primaryResource)}.approval-or-idempotency`,
|
|
1396
|
+
description: `${primaryResource} template must enforce approval or idempotency guard`,
|
|
1397
|
+
bind_to: lastRef,
|
|
1398
|
+
status: 'active'
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return rules;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function buildDecisionLogic(pattern, bindings, primaryResource) {
|
|
1406
|
+
const lastRef = bindings[bindings.length - 1] ? bindings[bindings.length - 1].ref : null;
|
|
1407
|
+
const riskDecision = pattern === 'query'
|
|
1408
|
+
? 'Use low-risk dry-run defaults for query execution'
|
|
1409
|
+
: 'Use guarded execution with approval and retry policies';
|
|
1410
|
+
|
|
1411
|
+
return [
|
|
1412
|
+
{
|
|
1413
|
+
id: `decision.${toKebabCase(primaryResource)}.risk-strategy`,
|
|
1414
|
+
description: riskDecision,
|
|
1415
|
+
bind_to: lastRef,
|
|
1416
|
+
status: 'resolved',
|
|
1417
|
+
automated: true
|
|
1418
|
+
},
|
|
1419
|
+
{
|
|
1420
|
+
id: `decision.${toKebabCase(primaryResource)}.retry-strategy`,
|
|
1421
|
+
description: 'Apply timeout/retry profile derived from template contract',
|
|
1422
|
+
bind_to: lastRef,
|
|
1423
|
+
status: 'resolved',
|
|
1424
|
+
automated: true
|
|
1425
|
+
}
|
|
1426
|
+
];
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function buildAgentHints(pattern, primaryResource, bindings) {
|
|
1430
|
+
const complexity = pattern === 'query' ? 'low' : 'medium';
|
|
1431
|
+
const baseDuration = pattern === 'query' ? 1800 : 3000;
|
|
1432
|
+
const permissions = pattern === 'query'
|
|
1433
|
+
? ['moqui.read']
|
|
1434
|
+
: ['moqui.read', 'moqui.write'];
|
|
1435
|
+
|
|
1436
|
+
return {
|
|
1437
|
+
summary: `${pattern.toUpperCase()} template extracted for ${primaryResource} with Moqui-aware ontology`,
|
|
1438
|
+
complexity,
|
|
1439
|
+
estimated_duration_ms: baseDuration + (bindings.length * 150),
|
|
1440
|
+
required_permissions: permissions,
|
|
1441
|
+
suggested_sequence: bindings.map((binding) => binding.ref),
|
|
1442
|
+
rollback_strategy: pattern === 'query'
|
|
1443
|
+
? 'Re-run query with previous filters'
|
|
1444
|
+
: 'Reconcile idempotency key and rollback to pre-mutation snapshot'
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// ─── Scene Manifest Generation ────────────────────────────────────
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Generate a scene manifest object for a pattern match.
|
|
1452
|
+
* Produces a manifest with correct apiVersion, kind, bindings, model_scope,
|
|
1453
|
+
* and governance_contract based on the pattern type.
|
|
1454
|
+
*
|
|
1455
|
+
* Pattern rules for bindings:
|
|
1456
|
+
* - "crud": 5 bindings (list, get = query; create, update, delete = mutation with side_effect)
|
|
1457
|
+
* - "query": 2 bindings (list, get = query)
|
|
1458
|
+
* - "workflow": service invoke bindings (type: 'invoke', ref from bindingRefs)
|
|
1459
|
+
*
|
|
1460
|
+
* Governance rules:
|
|
1461
|
+
* - "query": risk_level "low", approval.required false, no idempotency
|
|
1462
|
+
* - "crud"/"workflow": risk_level "medium", approval.required true, idempotency.required true
|
|
1463
|
+
*
|
|
1464
|
+
* @param {PatternMatch} match - Matched pattern
|
|
1465
|
+
* @returns {Object|null} Scene manifest object, or null for invalid input
|
|
1466
|
+
*/
|
|
1467
|
+
function generateSceneManifest(match) {
|
|
1468
|
+
if (!match || !match.pattern || !match.primaryResource) {
|
|
1469
|
+
return null;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
const pattern = match.pattern;
|
|
1473
|
+
const primaryResource = match.primaryResource;
|
|
1474
|
+
const packageName = derivePackageName(match);
|
|
1475
|
+
const gov = match.governance || {};
|
|
1476
|
+
|
|
1477
|
+
const baseBindings = buildBaseBindings(match);
|
|
1478
|
+
const bindings = addBindingSemantics(baseBindings, primaryResource);
|
|
1479
|
+
|
|
1480
|
+
// Build model_scope from match
|
|
1481
|
+
const modelScope = match.modelScope || { read: [], write: [] };
|
|
1482
|
+
|
|
1483
|
+
// Build governance_contract based on pattern type
|
|
1484
|
+
const riskLevel = gov.riskLevel || (pattern === 'query' ? 'low' : 'medium');
|
|
1485
|
+
const approvalRequired = gov.approvalRequired !== undefined
|
|
1486
|
+
? gov.approvalRequired
|
|
1487
|
+
: (pattern !== 'query');
|
|
1488
|
+
const idempotencyRequired = gov.idempotencyRequired !== undefined
|
|
1489
|
+
? gov.idempotencyRequired
|
|
1490
|
+
: (pattern !== 'query');
|
|
1491
|
+
const idempotencyKey = gov.idempotencyKey || deriveIdempotencyKey(
|
|
1492
|
+
Array.isArray(match.entities) && match.entities.length > 0
|
|
1493
|
+
? match.entities[0]
|
|
1494
|
+
: primaryResource
|
|
1495
|
+
);
|
|
1496
|
+
|
|
1497
|
+
const governanceContract = {
|
|
1498
|
+
risk_level: riskLevel,
|
|
1499
|
+
approval: {
|
|
1500
|
+
required: approvalRequired
|
|
1501
|
+
},
|
|
1502
|
+
data_lineage: buildDataLineage(bindings, pattern, primaryResource)
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1505
|
+
if (idempotencyRequired) {
|
|
1506
|
+
governanceContract.idempotency = {
|
|
1507
|
+
required: true,
|
|
1508
|
+
key: idempotencyKey
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Build intent goal based on pattern type
|
|
1513
|
+
let goal;
|
|
1514
|
+
|
|
1515
|
+
if (pattern === 'crud') {
|
|
1516
|
+
goal = `Full CRUD operations for ${primaryResource} entity`;
|
|
1517
|
+
} else if (pattern === 'query') {
|
|
1518
|
+
goal = `Read-only access to ${primaryResource} entity`;
|
|
1519
|
+
} else if (pattern === 'workflow') {
|
|
1520
|
+
goal = `Workflow orchestration for ${primaryResource} service`;
|
|
1521
|
+
} else {
|
|
1522
|
+
goal = `Operations for ${primaryResource}`;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
return {
|
|
1526
|
+
apiVersion: SCENE_API_VERSION,
|
|
1527
|
+
kind: 'scene',
|
|
1528
|
+
metadata: {
|
|
1529
|
+
obj_id: `scene.extracted.${packageName}`,
|
|
1530
|
+
obj_version: '0.1.0',
|
|
1531
|
+
title: `${pattern.charAt(0).toUpperCase() + pattern.slice(1)} ${primaryResource} Template`
|
|
1532
|
+
},
|
|
1533
|
+
spec: {
|
|
1534
|
+
domain: 'erp',
|
|
1535
|
+
intent: {
|
|
1536
|
+
goal
|
|
1537
|
+
},
|
|
1538
|
+
model_scope: {
|
|
1539
|
+
read: Array.isArray(modelScope.read) ? [...modelScope.read] : [],
|
|
1540
|
+
write: Array.isArray(modelScope.write) ? [...modelScope.write] : []
|
|
1541
|
+
},
|
|
1542
|
+
capability_contract: {
|
|
1543
|
+
bindings
|
|
1544
|
+
},
|
|
1545
|
+
governance_contract: governanceContract
|
|
1546
|
+
}
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// ─── Package Contract Generation ──────────────────────────────────
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Generate a package contract object for a pattern match.
|
|
1554
|
+
* Produces a contract with correct apiVersion, kind, metadata, parameters,
|
|
1555
|
+
* artifacts, and governance fields.
|
|
1556
|
+
*
|
|
1557
|
+
* @param {PatternMatch} match - Matched pattern
|
|
1558
|
+
* @returns {Object|null} Package contract object, or null for invalid input
|
|
1559
|
+
*/
|
|
1560
|
+
function generatePackageContract(match) {
|
|
1561
|
+
if (!match || !match.pattern || !match.primaryResource) {
|
|
1562
|
+
return null;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const pattern = match.pattern;
|
|
1566
|
+
const primaryResource = match.primaryResource;
|
|
1567
|
+
const packageName = derivePackageName(match);
|
|
1568
|
+
const gov = match.governance || {};
|
|
1569
|
+
const baseBindings = buildBaseBindings(match);
|
|
1570
|
+
const bindings = addBindingSemantics(baseBindings, primaryResource);
|
|
1571
|
+
const riskLevel = gov.riskLevel || (pattern === 'query' ? 'low' : 'medium');
|
|
1572
|
+
const approvalRequired = gov.approvalRequired !== undefined
|
|
1573
|
+
? gov.approvalRequired
|
|
1574
|
+
: (pattern !== 'query');
|
|
1575
|
+
const idempotencyRequired = gov.idempotencyRequired !== undefined
|
|
1576
|
+
? gov.idempotencyRequired
|
|
1577
|
+
: (pattern !== 'query');
|
|
1578
|
+
const idempotencyKey = gov.idempotencyKey || deriveIdempotencyKey(
|
|
1579
|
+
Array.isArray(match.entities) && match.entities.length > 0
|
|
1580
|
+
? match.entities[0]
|
|
1581
|
+
: primaryResource
|
|
1582
|
+
);
|
|
1583
|
+
const entityRefs = buildEntityRefs(match, primaryResource);
|
|
1584
|
+
const relations = buildEntityRelations(entityRefs);
|
|
1585
|
+
const hasMetadataView = entityRefs.some((entity) => entity.id === 'metadata_view');
|
|
1586
|
+
|
|
1587
|
+
if (!hasMetadataView) {
|
|
1588
|
+
entityRefs.push({ id: 'metadata_view', type: 'projection' });
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const hasMetadataRelation = relations.some((relation) => (
|
|
1592
|
+
relation.source === entityRefs[0].id
|
|
1593
|
+
&& relation.target === 'metadata_view'
|
|
1594
|
+
&& relation.type === 'produces'
|
|
1595
|
+
));
|
|
1596
|
+
|
|
1597
|
+
if (!hasMetadataRelation) {
|
|
1598
|
+
relations.push({
|
|
1599
|
+
source: entityRefs[0].id,
|
|
1600
|
+
target: 'metadata_view',
|
|
1601
|
+
type: 'produces'
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// Derive summary based on pattern type
|
|
1606
|
+
let summary;
|
|
1607
|
+
|
|
1608
|
+
if (pattern === 'crud') {
|
|
1609
|
+
summary = `CRUD template for ${primaryResource} entity extracted from Moqui ERP`;
|
|
1610
|
+
} else if (pattern === 'query') {
|
|
1611
|
+
summary = `Query template for ${primaryResource} entity extracted from Moqui ERP`;
|
|
1612
|
+
} else if (pattern === 'workflow') {
|
|
1613
|
+
summary = `Workflow template for ${primaryResource} service extracted from Moqui ERP`;
|
|
1614
|
+
} else {
|
|
1615
|
+
summary = `Template for ${primaryResource} extracted from Moqui ERP`;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const governanceContract = {
|
|
1619
|
+
risk_level: riskLevel,
|
|
1620
|
+
approval: {
|
|
1621
|
+
required: approvalRequired
|
|
1622
|
+
},
|
|
1623
|
+
data_lineage: buildDataLineage(bindings, pattern, primaryResource),
|
|
1624
|
+
business_rules: buildBusinessRules(pattern, bindings, primaryResource),
|
|
1625
|
+
decision_logic: buildDecisionLogic(pattern, bindings, primaryResource)
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
if (idempotencyRequired) {
|
|
1629
|
+
governanceContract.idempotency = {
|
|
1630
|
+
required: true,
|
|
1631
|
+
key: idempotencyKey
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
return {
|
|
1636
|
+
apiVersion: PACKAGE_API_VERSION,
|
|
1637
|
+
kind: 'scene-template',
|
|
1638
|
+
metadata: {
|
|
1639
|
+
group: 'kse.scene',
|
|
1640
|
+
name: packageName,
|
|
1641
|
+
version: '0.1.0',
|
|
1642
|
+
summary,
|
|
1643
|
+
description: `${summary}. Includes ontology graph hints, lineage tracing, and AI execution metadata.`
|
|
1644
|
+
},
|
|
1645
|
+
compatibility: {
|
|
1646
|
+
kse_version: '>=1.39.0',
|
|
1647
|
+
scene_api_version: SCENE_API_VERSION
|
|
1648
|
+
},
|
|
1649
|
+
parameters: [
|
|
1650
|
+
{
|
|
1651
|
+
id: 'timeout_ms',
|
|
1652
|
+
type: 'number',
|
|
1653
|
+
required: false,
|
|
1654
|
+
default: 2000,
|
|
1655
|
+
description: 'Request timeout in milliseconds'
|
|
1656
|
+
},
|
|
1657
|
+
{
|
|
1658
|
+
id: 'retry_count',
|
|
1659
|
+
type: 'number',
|
|
1660
|
+
required: false,
|
|
1661
|
+
default: 0,
|
|
1662
|
+
description: 'Number of retry attempts'
|
|
1663
|
+
}
|
|
1664
|
+
],
|
|
1665
|
+
artifacts: {
|
|
1666
|
+
entry_scene: 'scene.yaml',
|
|
1667
|
+
generates: ['scene.yaml', 'scene-package.json']
|
|
1668
|
+
},
|
|
1669
|
+
governance: {
|
|
1670
|
+
risk_level: riskLevel,
|
|
1671
|
+
approval_required: approvalRequired,
|
|
1672
|
+
approval: {
|
|
1673
|
+
required: approvalRequired
|
|
1674
|
+
},
|
|
1675
|
+
idempotency: {
|
|
1676
|
+
required: idempotencyRequired,
|
|
1677
|
+
key: idempotencyKey
|
|
1678
|
+
},
|
|
1679
|
+
rollback_supported: true
|
|
1680
|
+
},
|
|
1681
|
+
capability_contract: {
|
|
1682
|
+
bindings
|
|
1683
|
+
},
|
|
1684
|
+
governance_contract: governanceContract,
|
|
1685
|
+
ontology_model: {
|
|
1686
|
+
entities: entityRefs,
|
|
1687
|
+
relations
|
|
1688
|
+
},
|
|
1689
|
+
agent_hints: buildAgentHints(pattern, primaryResource, bindings)
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// ─── File Writing ─────────────────────────────────────────────────
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* Write template bundles to the output directory.
|
|
1697
|
+
* Creates one subdirectory per bundle containing scene.yaml and scene-package.json.
|
|
1698
|
+
* Partial failure resilient: if one bundle fails, continues with remaining bundles.
|
|
1699
|
+
*
|
|
1700
|
+
* @param {TemplateBundleOutput[]} bundles - Generated bundles
|
|
1701
|
+
* @param {string} outDir - Output directory path
|
|
1702
|
+
* @param {Object} [fileSystem] - fs-extra compatible file system (for DI/testing)
|
|
1703
|
+
* @returns {Promise<WriteResult[]>}
|
|
1704
|
+
*/
|
|
1705
|
+
async function writeTemplateBundles(bundles, outDir, fileSystem) {
|
|
1706
|
+
// Handle null/empty bundles gracefully
|
|
1707
|
+
if (!bundles || !Array.isArray(bundles) || bundles.length === 0) {
|
|
1708
|
+
return [];
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const fs = fileSystem || require('fs-extra');
|
|
1712
|
+
const results = [];
|
|
1713
|
+
|
|
1714
|
+
// Ensure outDir exists (create recursively if needed)
|
|
1715
|
+
try {
|
|
1716
|
+
fs.ensureDirSync(outDir);
|
|
1717
|
+
} catch (err) {
|
|
1718
|
+
// If we can't create the output directory, all bundles fail
|
|
1719
|
+
for (const bundle of bundles) {
|
|
1720
|
+
results.push({
|
|
1721
|
+
bundleDir: bundle.bundleDir || '',
|
|
1722
|
+
success: false,
|
|
1723
|
+
error: `Failed to create output directory: ${err.message}`
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
return results;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// Write each bundle with partial failure resilience
|
|
1730
|
+
for (const bundle of bundles) {
|
|
1731
|
+
const bundleDir = bundle.bundleDir || '';
|
|
1732
|
+
const bundlePath = path.join(outDir, bundleDir);
|
|
1733
|
+
|
|
1734
|
+
try {
|
|
1735
|
+
// Create subdirectory for this bundle
|
|
1736
|
+
fs.ensureDirSync(bundlePath);
|
|
1737
|
+
|
|
1738
|
+
// Write scene.yaml
|
|
1739
|
+
const sceneYamlPath = path.join(bundlePath, 'scene.yaml');
|
|
1740
|
+
fs.writeFileSync(sceneYamlPath, bundle.manifestYaml || '');
|
|
1741
|
+
|
|
1742
|
+
// Write scene-package.json
|
|
1743
|
+
const packageJsonPath = path.join(bundlePath, 'scene-package.json');
|
|
1744
|
+
fs.writeFileSync(packageJsonPath, bundle.contractJson || '');
|
|
1745
|
+
|
|
1746
|
+
results.push({ bundleDir, success: true });
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
// Catch error, add to results, continue with remaining bundles
|
|
1749
|
+
results.push({
|
|
1750
|
+
bundleDir,
|
|
1751
|
+
success: false,
|
|
1752
|
+
error: err.message
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
return results;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// ─── Discovery ────────────────────────────────────────────────────
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Catalog endpoint definitions for Moqui resource discovery.
|
|
1764
|
+
* Each entry maps a resource type to its API endpoint and response key.
|
|
1765
|
+
*/
|
|
1766
|
+
const CATALOG_ENDPOINTS = {
|
|
1767
|
+
entities: { path: '/api/v1/entities', key: 'entities' },
|
|
1768
|
+
services: { path: '/api/v1/services', key: 'services' },
|
|
1769
|
+
screens: { path: '/api/v1/screens', key: 'screens' }
|
|
1770
|
+
};
|
|
1771
|
+
|
|
1772
|
+
/**
|
|
1773
|
+
* Discover resources from a Moqui instance.
|
|
1774
|
+
* Queries catalog endpoints with optional type filtering and partial failure handling.
|
|
1775
|
+
*
|
|
1776
|
+
* @param {MoquiClient} client - Authenticated MoquiClient instance
|
|
1777
|
+
* @param {Object} [options] - Discovery options
|
|
1778
|
+
* @param {string} [options.type] - Filter: 'entities' | 'services' | 'screens'
|
|
1779
|
+
* @returns {Promise<{ entities: string[], services: string[], screens: string[], warnings: string[] }>}
|
|
1780
|
+
*/
|
|
1781
|
+
async function discoverResources(client, options = {}) {
|
|
1782
|
+
const result = {
|
|
1783
|
+
entities: [],
|
|
1784
|
+
services: [],
|
|
1785
|
+
screens: [],
|
|
1786
|
+
warnings: []
|
|
1787
|
+
};
|
|
1788
|
+
|
|
1789
|
+
// Determine which types to query
|
|
1790
|
+
const typesToQuery = options.type
|
|
1791
|
+
? [options.type]
|
|
1792
|
+
: ['entities', 'services', 'screens'];
|
|
1793
|
+
|
|
1794
|
+
for (const typeName of typesToQuery) {
|
|
1795
|
+
const endpoint = CATALOG_ENDPOINTS[typeName];
|
|
1796
|
+
if (!endpoint) {
|
|
1797
|
+
result.warnings.push(`Unknown resource type: ${typeName}`);
|
|
1798
|
+
continue;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
try {
|
|
1802
|
+
const response = await client.request('GET', endpoint.path);
|
|
1803
|
+
|
|
1804
|
+
if (!response || !response.success) {
|
|
1805
|
+
const errMsg = (response && response.error && response.error.message)
|
|
1806
|
+
|| `Failed to query ${typeName}`;
|
|
1807
|
+
result.warnings.push(`${typeName}: ${errMsg}`);
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// Extract data from response
|
|
1812
|
+
const rawData = response.data;
|
|
1813
|
+
let items;
|
|
1814
|
+
|
|
1815
|
+
if (Array.isArray(rawData)) {
|
|
1816
|
+
items = rawData;
|
|
1817
|
+
} else if (rawData && typeof rawData === 'object') {
|
|
1818
|
+
items = rawData[endpoint.key] || rawData.items || [];
|
|
1819
|
+
} else {
|
|
1820
|
+
items = [];
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// Ensure items is an array of strings
|
|
1824
|
+
result[typeName] = Array.isArray(items)
|
|
1825
|
+
? items.map(item => (typeof item === 'string' ? item : String(item)))
|
|
1826
|
+
: [];
|
|
1827
|
+
} catch (err) {
|
|
1828
|
+
// Partial failure: continue with other endpoints
|
|
1829
|
+
result.warnings.push(`${typeName}: ${err.message}`);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
return result;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// ─── Extraction Pipeline ──────────────────────────────────────────
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* Default output directory for extracted templates.
|
|
1840
|
+
*/
|
|
1841
|
+
const DEFAULT_OUT_DIR = '.kiro/templates/extracted';
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* Run the full extraction pipeline.
|
|
1845
|
+
* Orchestrates: config loading → client creation → login → discover → analyze → generate → write (or dry-run) → dispose.
|
|
1846
|
+
*
|
|
1847
|
+
* @param {Object} [options] - Extraction options
|
|
1848
|
+
* @param {string} [options.config] - Path to moqui-adapter.json
|
|
1849
|
+
* @param {string} [options.type] - Filter: 'entities' | 'services' | 'screens'
|
|
1850
|
+
* @param {string} [options.pattern] - Filter: 'crud' | 'query' | 'workflow'
|
|
1851
|
+
* @param {string} [options.out] - Output directory path
|
|
1852
|
+
* @param {boolean} [options.dryRun] - Preview without writing files
|
|
1853
|
+
* @param {boolean} [options.json] - Output as JSON
|
|
1854
|
+
* @param {Object} [dependencies] - Dependency injection for testing
|
|
1855
|
+
* @param {string} [dependencies.projectRoot] - Project root directory
|
|
1856
|
+
* @param {Object} [dependencies.fileSystem] - fs-extra compatible file system
|
|
1857
|
+
* @param {MoquiClient} [dependencies.client] - Pre-configured MoquiClient (skips config/login)
|
|
1858
|
+
* @returns {Promise<ExtractionResult>}
|
|
1859
|
+
*/
|
|
1860
|
+
async function runExtraction(options = {}, dependencies = {}) {
|
|
1861
|
+
const outDir = options.out || DEFAULT_OUT_DIR;
|
|
1862
|
+
let client = dependencies.client || null;
|
|
1863
|
+
let clientOwned = false; // Track if we created the client (and thus must dispose it)
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* Build an error ExtractionResult.
|
|
1867
|
+
*/
|
|
1868
|
+
function makeErrorResult(code, message, warnings = []) {
|
|
1869
|
+
return {
|
|
1870
|
+
success: false,
|
|
1871
|
+
templates: [],
|
|
1872
|
+
summary: {
|
|
1873
|
+
totalTemplates: 0,
|
|
1874
|
+
patterns: { crud: 0, query: 0, workflow: 0 },
|
|
1875
|
+
outputDir: outDir
|
|
1876
|
+
},
|
|
1877
|
+
warnings,
|
|
1878
|
+
error: { code, message }
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
try {
|
|
1883
|
+
// ── Step 1: Load and validate config (skip if client injected) ──
|
|
1884
|
+
if (!client) {
|
|
1885
|
+
const projectRoot = dependencies.projectRoot || process.cwd();
|
|
1886
|
+
const configResult = loadAdapterConfig(options.config, projectRoot);
|
|
1887
|
+
|
|
1888
|
+
if (configResult.error) {
|
|
1889
|
+
const code = configResult.error.startsWith('CONFIG_NOT_FOUND')
|
|
1890
|
+
? 'CONFIG_NOT_FOUND'
|
|
1891
|
+
: 'CONFIG_INVALID';
|
|
1892
|
+
return makeErrorResult(code, configResult.error);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
const validation = validateAdapterConfig(configResult.config);
|
|
1896
|
+
if (!validation.valid) {
|
|
1897
|
+
return makeErrorResult(
|
|
1898
|
+
'CONFIG_INVALID',
|
|
1899
|
+
`Invalid adapter config: ${validation.errors.join('; ')}`
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
// ── Step 2: Create MoquiClient ──
|
|
1904
|
+
client = new MoquiClient(configResult.config);
|
|
1905
|
+
clientOwned = true;
|
|
1906
|
+
|
|
1907
|
+
// ── Step 3: Login ──
|
|
1908
|
+
const loginResult = await client.login();
|
|
1909
|
+
if (!loginResult.success) {
|
|
1910
|
+
return makeErrorResult(
|
|
1911
|
+
'AUTH_FAILED',
|
|
1912
|
+
loginResult.error || 'Authentication failed'
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// ── Step 4: Discover resources ──
|
|
1918
|
+
const discovery = await discoverResources(client, { type: options.type });
|
|
1919
|
+
const warnings = [...discovery.warnings];
|
|
1920
|
+
|
|
1921
|
+
// ── Step 5: Analyze resources ──
|
|
1922
|
+
const matches = analyzeResources(discovery, { pattern: options.pattern });
|
|
1923
|
+
|
|
1924
|
+
// ── Step 6: Generate manifests and contracts ──
|
|
1925
|
+
const templates = [];
|
|
1926
|
+
const patternCounts = { crud: 0, query: 0, workflow: 0 };
|
|
1927
|
+
|
|
1928
|
+
for (const match of matches) {
|
|
1929
|
+
const manifest = generateSceneManifest(match);
|
|
1930
|
+
const contract = generatePackageContract(match);
|
|
1931
|
+
const manifestYaml = serializeManifestToYaml(manifest);
|
|
1932
|
+
const contractJson = JSON.stringify(contract, null, 2);
|
|
1933
|
+
const bundleDir = deriveBundleDirName(match);
|
|
1934
|
+
|
|
1935
|
+
templates.push({
|
|
1936
|
+
bundleDir,
|
|
1937
|
+
manifest,
|
|
1938
|
+
contract,
|
|
1939
|
+
manifestYaml,
|
|
1940
|
+
contractJson
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
if (patternCounts[match.pattern] !== undefined) {
|
|
1944
|
+
patternCounts[match.pattern]++;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// ── Step 7: Write bundles (unless dry-run) ──
|
|
1949
|
+
if (!options.dryRun && templates.length > 0) {
|
|
1950
|
+
const fs = dependencies.fileSystem || undefined;
|
|
1951
|
+
const writeResults = await writeTemplateBundles(templates, outDir, fs);
|
|
1952
|
+
|
|
1953
|
+
// Collect write warnings
|
|
1954
|
+
for (const wr of writeResults) {
|
|
1955
|
+
if (!wr.success) {
|
|
1956
|
+
warnings.push(`Write failed for ${wr.bundleDir}: ${wr.error}`);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// ── Step 8: Build and return ExtractionResult ──
|
|
1962
|
+
return {
|
|
1963
|
+
success: true,
|
|
1964
|
+
templates,
|
|
1965
|
+
summary: {
|
|
1966
|
+
totalTemplates: templates.length,
|
|
1967
|
+
patterns: patternCounts,
|
|
1968
|
+
outputDir: outDir
|
|
1969
|
+
},
|
|
1970
|
+
warnings,
|
|
1971
|
+
error: null
|
|
1972
|
+
};
|
|
1973
|
+
} catch (err) {
|
|
1974
|
+
// Wrap unexpected errors
|
|
1975
|
+
const code = (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT')
|
|
1976
|
+
? 'NETWORK_ERROR'
|
|
1977
|
+
: 'EXTRACT_FAILED';
|
|
1978
|
+
return makeErrorResult(code, err.message);
|
|
1979
|
+
} finally {
|
|
1980
|
+
// ── Always dispose client if we own it ──
|
|
1981
|
+
if (client && clientOwned) {
|
|
1982
|
+
try {
|
|
1983
|
+
await client.dispose();
|
|
1984
|
+
} catch (_) {
|
|
1985
|
+
// Ignore dispose errors
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// ─── Module Exports ───────────────────────────────────────────────
|
|
1992
|
+
|
|
1993
|
+
module.exports = {
|
|
1994
|
+
// Constants
|
|
1995
|
+
SUPPORTED_PATTERNS,
|
|
1996
|
+
HEADER_ITEM_SUFFIXES,
|
|
1997
|
+
SCENE_API_VERSION,
|
|
1998
|
+
PACKAGE_API_VERSION,
|
|
1999
|
+
CATALOG_ENDPOINTS,
|
|
2000
|
+
DEFAULT_OUT_DIR,
|
|
2001
|
+
// YAML functions
|
|
2002
|
+
serializeManifestToYaml,
|
|
2003
|
+
parseYaml,
|
|
2004
|
+
// Entity grouping
|
|
2005
|
+
groupRelatedEntities,
|
|
2006
|
+
// Pattern matching
|
|
2007
|
+
matchEntityPattern,
|
|
2008
|
+
matchWorkflowPatterns,
|
|
2009
|
+
// Resource analysis
|
|
2010
|
+
analyzeResources,
|
|
2011
|
+
// Generation
|
|
2012
|
+
generateSceneManifest,
|
|
2013
|
+
generatePackageContract,
|
|
2014
|
+
// Name derivation
|
|
2015
|
+
deriveBundleDirName,
|
|
2016
|
+
derivePackageName,
|
|
2017
|
+
// File writing
|
|
2018
|
+
writeTemplateBundles,
|
|
2019
|
+
// Discovery & extraction pipeline
|
|
2020
|
+
discoverResources,
|
|
2021
|
+
runExtraction,
|
|
2022
|
+
// Internal helpers (exported for testing)
|
|
2023
|
+
needsYamlQuoting,
|
|
2024
|
+
formatYamlValue,
|
|
2025
|
+
parseScalarValue,
|
|
2026
|
+
toKebabCase,
|
|
2027
|
+
deriveIdempotencyKey,
|
|
2028
|
+
generateEntityModelScope
|
|
2029
|
+
};
|