@aifabrix/builder 2.60.0 → 2.61.2

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.
Files changed (112) hide show
  1. package/README.md +14 -11
  2. package/docs/README.md +80 -0
  3. package/docs/builder-help/evidence-patterns.json +155 -0
  4. package/docs/builder-help/golden-examples/crm-company.json +30 -0
  5. package/docs/builder-help/golden-examples/crm-deal.json +29 -0
  6. package/docs/builder-help/golden-examples/document-storage-keyed-get.json +85 -0
  7. package/docs/builder-help/golden-examples/document-storage.json +30 -0
  8. package/docs/builder-help/golden-examples/meeting-transcript.json +29 -0
  9. package/docs/builder-help/golden-examples/repository-template.json +29 -0
  10. package/docs/builder-help/golden-examples/service-ticket.json +29 -0
  11. package/docs/builder-help/platform-roles.json +98 -0
  12. package/docs/builder-help/resource-type-catalog.json +402 -0
  13. package/lib/api/configuration.api.js +131 -0
  14. package/lib/api/system-secrets.api.js +72 -0
  15. package/lib/app/run-docker-fallback.js +6 -1
  16. package/lib/app/run-helpers.js +2 -1
  17. package/lib/app/run-parameter-sync.js +142 -0
  18. package/lib/build/docker-build-args.js +5 -2
  19. package/lib/build/index.js +3 -2
  20. package/lib/build/standard-docker-build.js +2 -2
  21. package/lib/cli/setup-app.js +5 -0
  22. package/lib/cli/setup-environment.js +156 -0
  23. package/lib/commands/datasource-capability-upsert-cli.js +112 -0
  24. package/lib/commands/datasource-capability.js +4 -2
  25. package/lib/commands/env-secret-context.js +113 -0
  26. package/lib/commands/env-secret-list.js +200 -0
  27. package/lib/commands/env-secret-push-confirm.js +54 -0
  28. package/lib/commands/env-secret-push-run.js +227 -0
  29. package/lib/commands/env-secret-push.js +168 -0
  30. package/lib/commands/repair-datasource-apply.js +2 -0
  31. package/lib/commands/repair-datasource-keyed-document.js +122 -0
  32. package/lib/commands/repair-datasource-run.js +1 -0
  33. package/lib/commands/setup-modes.js +1 -8
  34. package/lib/commands/setup-prompts.js +2 -180
  35. package/lib/commands/verify-operations-skip-e2e.js +25 -1
  36. package/lib/commands/verify-operations-steps.js +13 -1
  37. package/lib/commands/wizard-config-normalizer.js +7 -4
  38. package/lib/commands/wizard-core.js +5 -157
  39. package/lib/commands/wizard-file-saving.js +163 -0
  40. package/lib/core/env-platform-expand.js +5 -1
  41. package/lib/core/secrets-env-content.js +5 -1
  42. package/lib/core/secrets-env-write.js +10 -3
  43. package/lib/core/secrets-load.js +4 -2
  44. package/lib/datasource/binary-documents-validator.js +190 -0
  45. package/lib/datasource/capability/run-capability-upsert.js +202 -0
  46. package/lib/datasource/capability/upsert-ingredients.js +291 -0
  47. package/lib/datasource/capability/upsert-operations.js +138 -0
  48. package/lib/datasource/capability/upsert-test-scaffold.js +189 -0
  49. package/lib/datasource/validate.js +12 -5
  50. package/lib/generator/index.js +3 -0
  51. package/lib/lifecycle/product-model.js +4 -3
  52. package/lib/lifecycle/report-display.js +3 -2
  53. package/lib/parameters/infra-parameter-catalog.js +1 -1
  54. package/lib/programmatic/builder-help-enterprise-sync-fabrix.js +1 -1
  55. package/lib/programmatic/builder-help-governance.js +1 -1
  56. package/lib/programmatic/builder-help.js +1 -1
  57. package/lib/role-assistant/test-runner-workhub-answers.js +4 -1
  58. package/lib/role-assistant/test-runner-workhub-missing-fields.js +42 -0
  59. package/lib/role-assistant/test-runner-workhub-wait-stop.js +94 -0
  60. package/lib/role-assistant/test-runner-workhub.js +28 -19
  61. package/lib/schema/application-schema.json +14 -2
  62. package/lib/schema/external-datasource.schema.json +23 -3
  63. package/lib/schema/infra.parameter.yaml +388 -38
  64. package/lib/utils/compose-generate-docker-compose.js +7 -12
  65. package/lib/utils/datasource-binary-evidence.js +92 -0
  66. package/lib/utils/datasource-test-run-capability-scope.js +44 -1
  67. package/lib/utils/datasource-test-run-debug-display.js +2 -0
  68. package/lib/utils/datasource-test-run-display.js +8 -2
  69. package/lib/utils/datasource-test-run-issue-guidance.js +176 -0
  70. package/lib/utils/datasource-test-run-tty-log.js +2 -0
  71. package/lib/utils/env-copy.js +11 -10
  72. package/lib/utils/external-system-system-test-tty.js +3 -2
  73. package/lib/utils/image-tags.js +2 -2
  74. package/lib/utils/platform-kv-ref.js +1 -1
  75. package/lib/utils/prepare-local-data-mount.js +58 -0
  76. package/lib/utils/secrets-helpers.js +0 -1
  77. package/lib/utils/system-secret-mapping.js +125 -0
  78. package/lib/utils/test-log-writer.js +2 -1
  79. package/lib/validation/external-manifest-validator.js +5 -0
  80. package/lib/validation/openapi-contract-surface-validator.js +3 -1
  81. package/lib/validation/validate-external-file.js +5 -1
  82. package/package.json +4 -3
  83. package/templates/agent-kit/agent-kit.yaml +1 -1
  84. package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +2 -1
  85. package/templates/agent-kit/skills/aifabrix-connected-system/references/delivery-gates.md +15 -0
  86. package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +3 -2
  87. package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +6 -2
  88. package/templates/agent-kit/skills/aifabrix-prove/references/evidence-lifecycle.md +22 -0
  89. package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +7 -1
  90. package/templates/agent-kit/skills/aifabrix-role-assistant/references/testing-playbook.md +117 -0
  91. package/templates/agent-kit/skills/shared/interaction.md +83 -0
  92. package/templates/agent-kit/skills/shared/status.md +3 -12
  93. package/templates/applications/builder-api/application.yaml +1 -1
  94. package/templates/applications/builder-api/env.template +5 -1
  95. package/templates/applications/dataplane/application.yaml +1 -1
  96. package/templates/applications/dataplane/env.template +10 -12
  97. package/templates/applications/keycloak/application.yaml +6 -1
  98. package/templates/applications/miso-controller/application.yaml +74 -2
  99. package/templates/applications/miso-controller/env.template +42 -3
  100. package/templates/applications/miso-controller/rbac.yaml +17 -0
  101. package/templates/external-system/external-datasource.yaml.hbs +7 -1
  102. package/templates/marketplace/main.json +4 -4
  103. package/templates/python/docker-compose.hbs +1 -1
  104. package/templates/agent-kit/skills/aifabrix-plan/references/interaction.md +0 -41
  105. /package/{lib/programmatic/help-content → docs/builder-help/content}/channel-onboarding.md +0 -0
  106. /package/{lib/programmatic/help-content → docs/builder-help/content}/cip-overview.md +0 -0
  107. /package/{lib/programmatic/help-content → docs/builder-help/content}/connected-system-ui.md +0 -0
  108. /package/{lib/programmatic/help-content → docs/builder-help/content}/dimensions-guide.md +0 -0
  109. /package/{lib/programmatic/help-content → docs/builder-help/content}/enterprise-sync-fabrix.md +0 -0
  110. /package/{lib/programmatic/help-content → docs/builder-help/content}/overview.md +0 -0
  111. /package/{lib/programmatic/help-content → docs/builder-help/content}/subscription-guide.md +0 -0
  112. /package/{lib/programmatic/help-content → docs/builder-help/content}/workflow.md +0 -0
@@ -11,6 +11,7 @@
11
11
  const fsSync = require('fs');
12
12
  const path = require('path');
13
13
  const buildCopy = require('./build-copy');
14
+ const { prepareLocalDataMount } = require('./prepare-local-data-mount');
14
15
  const paths = require('./paths');
15
16
  const { isSystemBuilderAppName } = require('./paths-system-builder-keys');
16
17
  const {
@@ -187,14 +188,14 @@ function resolveStorageMountSourceRoot(appName, buildContext) {
187
188
  }
188
189
 
189
190
  /**
190
- * Host `mount/` directory bind-mounted to `/mnt/data` when storage is required.
191
+ * Host `mount/data/<appName>/` directory bind-mounted to `/mnt/data` when storage is required.
191
192
  *
192
193
  * @param {object} ctx - Compose template context (`devMountPath`, `appName`, `appConfig`, `serviceConfig`)
193
194
  * @returns {string|null}
194
195
  */
195
196
  function resolveLocalDataMountPathForCompose(ctx) {
196
197
  if (ctx.devMountPath && typeof ctx.devMountPath === 'string') {
197
- return path.join(ctx.devMountPath, 'mount').replace(/\\/g, '/');
198
+ return path.join(ctx.devMountPath, 'mount', 'data', ctx.appName).replace(/\\/g, '/');
198
199
  }
199
200
  const requiresStorage =
200
201
  ctx.serviceConfig?.requiresStorage ||
@@ -212,7 +213,7 @@ function resolveLocalDataMountPathForCompose(ctx) {
212
213
  if (!sourceRoot) {
213
214
  return null;
214
215
  }
215
- return path.join(sourceRoot, 'mount').replace(/\\/g, '/');
216
+ return path.join(sourceRoot, 'mount', 'data', ctx.appName).replace(/\\/g, '/');
216
217
  } catch {
217
218
  return null;
218
219
  }
@@ -229,12 +230,8 @@ function buildComposeTemplateContext(ctx) {
229
230
  ctx.appConfig?.requires?.storage ||
230
231
  ctx.appConfig?.services?.storage ||
231
232
  false;
232
- if (localDataMountPath && requiresStorage && !fsSync.existsSync(localDataMountPath)) {
233
- try {
234
- fsSync.mkdirSync(localDataMountPath, { recursive: true });
235
- } catch {
236
- // Non-fatal: tests may use non-writable devMountPath; host must create mount/ before run.
237
- }
233
+ if (localDataMountPath && requiresStorage) {
234
+ prepareLocalDataMount(localDataMountPath, ctx.envFileAbsolutePath);
238
235
  }
239
236
  return {
240
237
  ...ctx.serviceConfig,
@@ -253,9 +250,7 @@ function buildComposeTemplateContext(ctx) {
253
250
  // An app that administers its own operating system — creating accounts, managing
254
251
  // sshd — cannot work that way and declares `runAsRoot: true` to opt out.
255
252
  runAsRoot: ctx.appConfig?.runAsRoot === true,
256
- // Extra ports for an application serving a second protocol from the same container,
257
- // such as SSH beside HTTP. Each host port uses the main port's formula so developers
258
- // on one host do not collide.
253
+ // Extra protocol ports use the main port offset formula to avoid developer collisions.
259
254
  additionalPorts: (ctx.appConfig?.additionalPorts ?? []).map((entry) => ({
260
255
  name: entry.name,
261
256
  containerPort: entry.containerPort,
@@ -0,0 +1,92 @@
1
+ /** @fileoverview Safe binary metadata and keyed document persistence evidence. */
2
+ 'use strict';
3
+
4
+ const { keyedGetEvidence, hasExecutedKeyedGet } = require('./datasource-test-run-capability-scope');
5
+ const BINARY_FIELDS = new Set(['contentType', 'size', 'filename', 'sha256']);
6
+
7
+ /** @param {*} value @returns {Object} */
8
+ function binarySummary(value) {
9
+ if (!value || typeof value !== 'object') return {};
10
+ return Object.fromEntries(Object.entries(value).filter(([key, item]) =>
11
+ BINARY_FIELDS.has(key) && ['string', 'number'].includes(typeof item)));
12
+ }
13
+
14
+ /**
15
+ * Limit binary containers to metadata before printing or saving a report.
16
+ * Ordinary JSON record fields remain intact.
17
+ * @param {*} value
18
+ * @param {WeakSet} seen
19
+ * @returns {*}
20
+ */
21
+ function sanitizeBinaryEvidence(value, seen = new WeakSet()) {
22
+ if (!value || typeof value !== 'object') return value;
23
+ if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) return '[binary omitted]';
24
+ if (seen.has(value)) return '[Circular]';
25
+ seen.add(value);
26
+ if (Array.isArray(value)) {
27
+ const items = value.map(item => sanitizeBinaryEvidence(item, seen));
28
+ seen.delete(value);
29
+ return items;
30
+ }
31
+ const out = {};
32
+ for (const [key, item] of Object.entries(value)) {
33
+ if (['getBinary', 'listBinary', '_binary'].includes(key)) out[key] = binarySummary(item);
34
+ else if (value.keyedGet === true && key === 'document') {
35
+ out[key] = { ...binarySummary(item) };
36
+ if (typeof item?.key === 'string') out[key].key = item.key;
37
+ if (typeof item?.persisted === 'boolean') out[key].persisted = item.persisted;
38
+ } else if (value.keyedGet === true && ['data', 'bytes', 'body', 'content', 'base64'].includes(key)) continue;
39
+ else out[key] = sanitizeBinaryEvidence(item, seen);
40
+ }
41
+ seen.delete(value);
42
+ return out;
43
+ }
44
+
45
+ /** @param {Object} value @param {string[]} lines */
46
+ function appendBinaryAndPersistence(value, lines) {
47
+ if (!value || typeof value !== 'object') return;
48
+ if (value.getBinary) lines.push(` getBinary: ${JSON.stringify(binarySummary(value.getBinary))}`);
49
+ if (value.scope?.expectedSource === 'get') {
50
+ const observed = value.observed || {};
51
+ lines.push(` Document persistence (expectedSource: get): documents=${Number(observed.documents) || 0}, chunks=${Number(observed.chunks) || 0}`);
52
+ }
53
+ for (const [key, child] of Object.entries(value)) {
54
+ if (key !== 'getBinary') appendBinaryAndPersistence(child, lines);
55
+ }
56
+ }
57
+
58
+ /** @param {Object} envelope @returns {string[]} */
59
+ function binaryEvidenceLines(envelope) {
60
+ const lines = [];
61
+ for (const job of keyedGetEvidence(envelope)) {
62
+ const status = job.skipped === true ? 'skipped' : job.ok === true ? 'executed' : 'failed';
63
+ lines.push(` Keyed get: ${status} (${job.datasourceKey || envelope.datasourceKey || 'unknown'})`);
64
+ if (job.document) lines.push(` Document: ${JSON.stringify(binarySummary(job.document))}`);
65
+ }
66
+ appendBinaryAndPersistence(envelope?.integration?.stepResults, lines);
67
+ return lines;
68
+ }
69
+
70
+ /** @param {Object} envelope @param {string} key @returns {boolean} */
71
+ function hasKeyedDocumentPersistence(envelope, key) {
72
+ if (!hasExecutedKeyedGet(envelope, key)) return false;
73
+ const steps = envelope?.integration?.stepResults || [];
74
+ return steps.some(step => {
75
+ if (step.name !== 'document_persistence' || step.success === false || step.status === 'failed') return false;
76
+ const row = step.evidence?.datasources?.[key];
77
+ const model = row?.model;
78
+ return model?.scope?.expectedSource === 'get' && model.observed?.documents > 0 &&
79
+ model.observed?.chunks > 0 && (!Array.isArray(row.failures) || row.failures.length === 0);
80
+ });
81
+ }
82
+
83
+ /** @param {Object} result @param {string[]} keys @returns {boolean} */
84
+ function hasAllKeyedDocumentPersistence(result, keys) {
85
+ return keys.length > 0 && keys.every(key => {
86
+ const row = result?.results?.find(item => item.key === key);
87
+ return row?.success === true && hasKeyedDocumentPersistence(row.datasourceTestRun, key);
88
+ });
89
+ }
90
+
91
+ module.exports = { binarySummary, sanitizeBinaryEvidence, binaryEvidenceLines, hasKeyedDocumentPersistence,
92
+ hasAllKeyedDocumentPersistence };
@@ -4,6 +4,43 @@
4
4
  * @version 2.0.0
5
5
  */
6
6
 
7
+ /**
8
+ * Keyed document retrieval has no list operation, so the `get` runs once against a business
9
+ * key instead of over a sync page. The dataplane records that run as step evidence
10
+ * (`sync_step_keyed_get.py`), which is why execution is read from evidence rather than from
11
+ * a row count. `IntegrationStepResult.evidence` is an open object by contract.
12
+ * @param {Object|null|undefined} envelope - DatasourceTestRun-like
13
+ * @returns {Array<Object>} Keyed-get evidence entries, newest first as reported
14
+ */
15
+ function keyedGetEvidence(envelope) {
16
+ const integration = envelope && typeof envelope === 'object' ? envelope.integration : null;
17
+ const steps = integration && Array.isArray(integration.stepResults) ? integration.stepResults : [];
18
+ return steps
19
+ .map(step => (step && typeof step.evidence === 'object' ? step.evidence : null))
20
+ .flatMap(evidence => evidence && Array.isArray(evidence.jobs) ? evidence.jobs : [evidence])
21
+ .filter(evidence => evidence && evidence.keyedGet === true);
22
+ }
23
+
24
+ /**
25
+ * A keyed `get` counts as executed only when it actually returned a document. A skipped run
26
+ * (no primary key value) or a failed one is not execution, and reporting it as such would
27
+ * turn `--strict-capability-scope` into a rubber stamp.
28
+ * @param {Object|null|undefined} envelope - DatasourceTestRun-like
29
+ * @param {string|undefined|null} datasourceKey - Restrict to one datasource when given
30
+ * @returns {boolean} True when a keyed get ran successfully
31
+ */
32
+ function hasExecutedKeyedGet(envelope, datasourceKey) {
33
+ const wanted = datasourceKey === undefined || datasourceKey === null || String(datasourceKey).trim() === ''
34
+ ? ''
35
+ : String(datasourceKey).trim();
36
+ return keyedGetEvidence(envelope).some(evidence => {
37
+ if (evidence.ok !== true || evidence.skipped === true) {
38
+ return false;
39
+ }
40
+ return wanted === '' || String(evidence.datasourceKey || '') === wanted;
41
+ });
42
+ }
43
+
7
44
  /**
8
45
  * @param {Object|null|undefined} envelope - DatasourceTestRun-like
9
46
  * @param {string|undefined|null} requestedCapabilityKey - From positional [capabilityKey]
@@ -23,6 +60,10 @@ function analyzeCapabilityScope(envelope, requestedCapabilityKey) {
23
60
  envelope && typeof envelope === 'object' && Array.isArray(envelope.capabilities)
24
61
  ? envelope.capabilities
25
62
  : [];
63
+ if (key === 'get' && keyedGetEvidence(envelope).length > 0 &&
64
+ !hasExecutedKeyedGet(envelope, envelope.datasourceKey)) {
65
+ return { violated: true, message: 'Capabilities scope: keyed get was not executed successfully.' };
66
+ }
26
67
  if (caps.length <= 1) {
27
68
  return { violated: false };
28
69
  }
@@ -39,5 +80,7 @@ function analyzeCapabilityScope(envelope, requestedCapabilityKey) {
39
80
  }
40
81
 
41
82
  module.exports = {
42
- analyzeCapabilityScope
83
+ analyzeCapabilityScope,
84
+ keyedGetEvidence,
85
+ hasExecutedKeyedGet
43
86
  };
@@ -4,6 +4,7 @@
4
4
  * @version 2.0.0
5
5
  */
6
6
 
7
+ const { sanitizeBinaryEvidence } = require('./datasource-binary-evidence');
7
8
  const { SEP, appendReferenceLayoutLines } = require('./datasource-test-run-display');
8
9
  const { buildDebugEnvelopeSlice } = require('./datasource-test-run-debug-slice');
9
10
  const {
@@ -250,6 +251,7 @@ function applyRawLineCap(text) {
250
251
  * @returns {string}
251
252
  */
252
253
  function formatDatasourceTestRunDebugBlock(envelope, mode, isTTY) {
254
+ envelope = sanitizeBinaryEvidence(envelope);
253
255
  if (!envelope || typeof envelope !== 'object') return '';
254
256
 
255
257
  const lines = ['', SEP, `Debug (${mode})`];
@@ -5,9 +5,11 @@
5
5
  */
6
6
 
7
7
  const chalk = require('chalk');
8
+ const { binaryEvidenceLines } = require('./datasource-binary-evidence');
8
9
  const { sectionTitle, headerKeyValue, colorAggregateGlyph, successGlyph, failureGlyph } = require('./cli-test-layout-chalk');
9
10
  const { appendCertificateTTY, appendCertificateIssuanceTTY } = require('./datasource-test-run-certificate-tty');
10
11
  const { buildTtyMetaLines: buildTtyMetaLinesCore } = require('./datasource-test-run-tty-meta-lines');
12
+ const { appendStructuredIssueGuidance } = require('./datasource-test-run-issue-guidance');
11
13
 
12
14
  const SEP = '────────────────────────────────';
13
15
 
@@ -297,9 +299,12 @@ function appendValidationIssueLines(lines, envelope, maxIssues = 5) {
297
299
  const cap = Math.min(maxIssues, issues.length);
298
300
  for (let i = 0; i < cap; i += 1) {
299
301
  const iss = issues[i];
300
- const code = iss && iss.code ? chalk.red(`[${iss.code}] `) : '';
302
+ const issueCode = iss && (iss.stableCode || iss.code);
303
+ const code = issueCode ? chalk.red(`[${issueCode}] `) : '';
304
+ const severity = iss?.severity ? chalk.gray(`[${String(iss.severity)}] `) : '';
301
305
  const msg = iss && iss.message ? String(iss.message) : JSON.stringify(iss);
302
- lines.push(` ${code}${chalk.yellow(msg)}`);
306
+ lines.push(` ${code}${severity}${chalk.yellow(msg)}`);
307
+ appendStructuredIssueGuidance(lines, iss);
303
308
  appendDpSec013Details(lines, iss);
304
309
  }
305
310
  if (issues.length > cap) {
@@ -448,6 +453,7 @@ function formatDatasourceTestRunTTY(envelope, options = {}) {
448
453
  appendRefsSectionIfEnabled(lines, envelope, options);
449
454
  appendValidationIssueLines(lines, envelope);
450
455
  appendIntegrationStepLines(lines, envelope);
456
+ lines.push(...binaryEvidenceLines(envelope));
451
457
  const focus = normalizedFocusCapabilityKey(options.focusCapabilityKey);
452
458
  if (focus) {
453
459
  lines.push(formatCapabilityFocusSection(envelope, focus));
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Structured human guidance for DatasourceTestRun validation issues.
3
+ *
4
+ * @fileoverview Preserve actionable issue fields in TTY output
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const chalk = require('chalk');
10
+
11
+ const STEP_VERBS = {
12
+ locate_file: 'open',
13
+ update_path: 'update',
14
+ add_mapping: 'add mapping',
15
+ set_value: 'set',
16
+ remove_value: 'remove',
17
+ add_item: 'add to',
18
+ remove_item: 'remove from'
19
+ };
20
+
21
+ const UPSERT_GUIDANCE = {
22
+ 'DP-CIP-030': {
23
+ observed: 'The declared upsert does not contain the required create, update, and records-output composition.',
24
+ consequence: 'The runtime cannot execute one deterministic create-or-update operation.',
25
+ aiFixable: true,
26
+ remediation: [
27
+ 'Run aifabrix datasource capability upsert <file-or-key> --overwrite --dry-run, review the patch, then apply and validate it.'
28
+ ],
29
+ prevention: 'Author upsert through the capability command and keep both source halves valid.',
30
+ residualRisk: 'Publication remains blocked while the composition is malformed.',
31
+ rerun: 'Rerun the same focused datasource validation or E2E case.'
32
+ },
33
+ 'DP-CIP-031': {
34
+ observed: 'The upsert declares no usable business identity.',
35
+ consequence: 'Existence cannot be resolved, so a retry may create a duplicate or update the wrong record.',
36
+ aiFixable: true,
37
+ remediation: [
38
+ 'Run aifabrix datasource capability upsert <file-or-key> --identity <fields> --dry-run with author-approved mapped non-primary fields.'
39
+ ],
40
+ prevention: 'Model business identity before exposing a composed write.',
41
+ residualRisk: 'Upsert must remain unavailable until identity is explicit and validated.',
42
+ rerun: 'Rerun the same focused datasource validation or E2E case.'
43
+ },
44
+ 'DP-CIP-040': {
45
+ observed: 'No declared read operation can constrain the complete business identity.',
46
+ consequence: 'The source record may be missed, creating a duplicate or selecting the wrong update target.',
47
+ aiFixable: false,
48
+ remediation: [
49
+ 'Declare a real exact lookup only if the source API supports it.',
50
+ 'Otherwise keep separate create and update capabilities and withhold upsert readiness.'
51
+ ],
52
+ prevention: 'Require complete equality lookup coverage before authoring upsert.',
53
+ residualRisk: 'AI cannot repair a source API that lacks exact lookup or uniqueness.',
54
+ rerun: 'After a supported lookup is declared, rerun the same focused validation and E2E proof.'
55
+ },
56
+ 'DP-CIP-041': {
57
+ observed: 'A covering identity read is declared, but live filtering has not been proven.',
58
+ consequence: 'Static coverage alone cannot show that the source applies the constraint or exposes writes in time.',
59
+ aiFixable: false,
60
+ remediation: ['Run the focused live validation and two-write no-duplicate E2E proof; do not add a duplicate read declaration.'],
61
+ prevention: 'Keep exact lookup and no-duplicate proof scenarios with every upsert datasource.',
62
+ residualRisk: 'Read-after-write delay can leave a bounded duplicate window until runtime protection is proven.',
63
+ rerun: 'Rerun the same focused datasource validation and upsert E2E case.'
64
+ }
65
+ };
66
+
67
+ function normalizeOptionalString(value) {
68
+ return typeof value === 'string' ? value.trim() : '';
69
+ }
70
+
71
+ function hasGuidanceValue(value) {
72
+ if (Array.isArray(value)) return value.some(hasGuidanceValue);
73
+ if (typeof value === 'boolean') return true;
74
+ if (value && typeof value === 'object') return Object.keys(value).length > 0;
75
+ return normalizeOptionalString(value) !== '';
76
+ }
77
+
78
+ function firstGuidanceValue(...values) {
79
+ return values.find(hasGuidanceValue);
80
+ }
81
+
82
+ function guidancePayload(item) {
83
+ return normalizeOptionalString(item.value) || normalizeOptionalString(item.template);
84
+ }
85
+
86
+ function guidanceVerb(step) {
87
+ return STEP_VERBS[step] || step.replace(/_/g, ' ') || 'update';
88
+ }
89
+
90
+ /**
91
+ * Render one guidance entry, which may be a string or a structured fix instruction.
92
+ *
93
+ * `fix.instructions[]` entries are objects ({step, path, value, template, note}),
94
+ * so stringifying them printed `[object Object]` and the remediation — the part a
95
+ * developer acts on — was lost.
96
+ *
97
+ * @param {unknown} item guidance entry
98
+ * @returns {string|null} display text, or null when there is nothing to show
99
+ */
100
+ function formatGuidanceItem(item) {
101
+ if (typeof item === 'string') return item.trim() || null;
102
+ if (!item || typeof item !== 'object') return null;
103
+
104
+ const step = normalizeOptionalString(item.step);
105
+ const note = normalizeOptionalString(item.note);
106
+ const target = normalizeOptionalString(item.path);
107
+ const payload = guidancePayload(item);
108
+
109
+ // `explain` carries no path or value; its note *is* the instruction.
110
+ if (step === 'explain' || (!target && !payload)) {
111
+ return note || (step ? step.replace(/_/g, ' ') : null);
112
+ }
113
+
114
+ const verb = guidanceVerb(step);
115
+ // With no path the payload *is* the target (e.g. locate_file carries a path in
116
+ // `value`), so it reads as "open <file>" rather than "open: <file>".
117
+ let text = target ? `${verb} ${target}` : `${verb} ${payload}`;
118
+ if (target && payload) text += step === 'set_value' ? ` = ${payload}` : `: ${payload}`;
119
+ return note ? `${text} — ${note}` : text;
120
+ }
121
+
122
+ function appendGuidanceValue(lines, label, value) {
123
+ const values = Array.isArray(value) ? value : [value];
124
+ values
125
+ .map(formatGuidanceItem)
126
+ .filter(Boolean)
127
+ .forEach((text) => {
128
+ lines.push(` ${chalk.gray(`${label}:`)} ${chalk.white(text)}`);
129
+ });
130
+ }
131
+
132
+ function enrichUpsertIssueGuidance(issue) {
133
+ if (!issue || typeof issue !== 'object') return issue;
134
+ const code = issue.stableCode || issue.code;
135
+ const fallback = UPSERT_GUIDANCE[code];
136
+ if (!fallback) return issue;
137
+ const enriched = { ...issue };
138
+ Object.entries(fallback).forEach(([key, value]) => {
139
+ if (!hasGuidanceValue(enriched[key])) enriched[key] = value;
140
+ });
141
+ return enriched;
142
+ }
143
+
144
+ function appendStructuredIssueGuidance(lines, issue) {
145
+ if (!issue || typeof issue !== 'object') return;
146
+ const display = enrichUpsertIssueGuidance(issue);
147
+ appendGuidanceValue(lines, 'Observed', firstGuidanceValue(
148
+ issue.observed, issue.details?.diagnosis, display.observed
149
+ ));
150
+ appendGuidanceValue(lines, 'Consequence', firstGuidanceValue(
151
+ issue.impact, issue.consequence, issue.reason, display.consequence
152
+ ));
153
+ if (typeof display.aiFixable === 'boolean') {
154
+ appendGuidanceValue(lines, 'Can AI fix?', display.aiFixable
155
+ ? 'Yes — only as an authorized deterministic local manifest change.'
156
+ : 'No automatic fix is safe for this source or live-proof limitation.');
157
+ }
158
+ appendGuidanceValue(lines, 'Safe action', firstGuidanceValue(
159
+ issue.fix?.instructions, issue.remediation, issue.hint, display.remediation
160
+ ));
161
+ appendGuidanceValue(lines, 'Prevention', firstGuidanceValue(
162
+ issue.prevention, issue.fix?.prevention, display.prevention
163
+ ));
164
+ appendGuidanceValue(lines, 'Residual risk', firstGuidanceValue(
165
+ issue.residualRisk, issue.risk, display.residualRisk
166
+ ));
167
+ appendGuidanceValue(lines, 'Rerun', firstGuidanceValue(
168
+ issue.rerun, issue.fix?.rerun, display.rerun
169
+ ));
170
+ }
171
+
172
+ module.exports = {
173
+ appendStructuredIssueGuidance,
174
+ enrichUpsertIssueGuidance,
175
+ formatGuidanceItem
176
+ };
@@ -5,6 +5,7 @@
5
5
  'use strict';
6
6
 
7
7
  const chalk = require('chalk');
8
+ const { sanitizeBinaryEvidence } = require('./datasource-binary-evidence');
8
9
  const logger = require('./logger');
9
10
  const { getReportVersionStderrMessage } = require('./datasource-test-run-report-version');
10
11
  const {
@@ -42,6 +43,7 @@ function emitCapabilityScopeDiagnostics(envelope, opts = {}) {
42
43
  * @param {string} [options.requestedCapabilityKey]
43
44
  */
44
45
  function printDatasourceTestRunForTTY(envelope, options = {}) {
46
+ envelope = sanitizeBinaryEvidence(envelope);
45
47
  const mode = resolveDebugDisplayMode(options.debug);
46
48
  const displayOpts = {
47
49
  focusCapabilityKey: options.requestedCapabilityKey,
@@ -56,16 +56,17 @@ function readDeveloperIdFromConfig(config) {
56
56
  * Creates the mount folder on the local filesystem (next to the .env file) when it does not exist.
57
57
  * @param {string} content - Env file content
58
58
  * @param {string} outputPath - Resolved path of the .env file being written
59
+ * @param {string} [appName] - System key (defaults to output directory name)
59
60
  * @returns {string} Content with /mnt/data replaced by path to mount directory
60
61
  */
61
- function substituteMntDataForLocal(content, outputPath) {
62
+ function substituteMntDataForLocal(content, outputPath, appName) {
62
63
  const outputDir = path.dirname(outputPath);
63
64
  // Avoid introducing a drive letter for root-relative paths (e.g. "\tmp\out"),
64
65
  // which are common in unit tests and some Windows path constructions.
65
66
  const isWinRootRelative = process.platform === 'win32' && /^\\(?!\\)/.test(outputDir);
66
67
  const localMountPath = isWinRootRelative
67
- ? path.win32.normalize(path.win32.join(outputDir, 'mount'))
68
- : path.resolve(outputDir, 'mount');
68
+ ? path.win32.normalize(path.win32.join(outputDir, 'mount', 'data', appName || path.basename(outputDir)))
69
+ : path.resolve(outputDir, 'mount', 'data', appName || path.basename(outputDir));
69
70
  if (!fs.existsSync(localMountPath)) {
70
71
  fs.mkdirSync(localMountPath, { recursive: true });
71
72
  }
@@ -140,12 +141,12 @@ async function writeEnvOutputForReload(outputPath, runEnvPath, appName) {
140
141
  baseContent = await fsp.readFile(outputPath, 'utf8');
141
142
  } else if (appName) {
142
143
  baseContent = await generateEnvContent(appName, null, 'local', false);
143
- baseContent = substituteMntDataForLocal(baseContent, outputPath);
144
+ baseContent = substituteMntDataForLocal(baseContent, outputPath, appName);
144
145
  } else {
145
146
  baseContent = runContent;
146
147
  }
147
148
  let toWrite = mergeEnvMapIntoContent(baseContent, runMap, { appendMissingFromNewMap: false });
148
- toWrite = substituteMntDataForLocal(toWrite, outputPath);
149
+ toWrite = substituteMntDataForLocal(toWrite, outputPath, appName);
149
150
  await fsp.writeFile(outputPath, toWrite, { mode: 0o600 });
150
151
  logger.log(
151
152
  formatSuccessLine(
@@ -163,7 +164,7 @@ async function writeEnvOutputForReload(outputPath, runEnvPath, appName) {
163
164
  async function writeEnvOutputForLocal(appName, outputPath) {
164
165
  const { generateEnvContent, parseEnvContentToMap, mergeEnvMapIntoContent } = require('../core/secrets');
165
166
  let localContent = await generateEnvContent(appName, null, 'local', false);
166
- localContent = substituteMntDataForLocal(localContent, outputPath);
167
+ localContent = substituteMntDataForLocal(localContent, outputPath, appName);
167
168
  let toWrite = localContent;
168
169
  if (fs.existsSync(outputPath)) {
169
170
  const existingContent = await fsp.readFile(outputPath, 'utf8');
@@ -319,7 +320,7 @@ async function patchEnvContentForLocal(envContent, variables) {
319
320
  async function syncWrittenBuilderEnvToOutputPath(envPath, outputPath, envOutputPathLabel, copyOptions = {}) {
320
321
  const { parseEnvContentToMap, mergeEnvMapIntoContent } = require('../core/secrets');
321
322
  const raw = fs.readFileSync(envPath, 'utf8');
322
- const synced = substituteMntDataForLocal(raw, outputPath);
323
+ const synced = substituteMntDataForLocal(raw, outputPath, copyOptions.appName || path.basename(path.dirname(envPath)));
323
324
  let toWrite = synced;
324
325
  if (!copyOptions.freshEnv && fs.existsSync(outputPath)) {
325
326
  const existingContent = fs.readFileSync(outputPath, 'utf8');
@@ -344,7 +345,7 @@ async function writeLocalEnvToOutputPath(outputPath, appName, secretsPath, envOu
344
345
  const { generateEnvContent, parseEnvContentToMap, mergeEnvMapIntoContent } = require('../core/secrets');
345
346
  const genOpts = extraOpts.appPath ? { appPath: extraOpts.appPath } : {};
346
347
  let localEnvContent = await generateEnvContent(appName, secretsPath, 'local', false, genOpts);
347
- localEnvContent = substituteMntDataForLocal(localEnvContent, outputPath);
348
+ localEnvContent = substituteMntDataForLocal(localEnvContent, outputPath, appName);
348
349
  let toWrite = localEnvContent;
349
350
  if (!extraOpts.freshEnv && fs.existsSync(outputPath)) {
350
351
  const existingContent = fs.readFileSync(outputPath, 'utf8');
@@ -366,7 +367,7 @@ async function writeLocalEnvToOutputPath(outputPath, appName, secretsPath, envOu
366
367
  async function writePatchedEnvToOutputPath(envPath, outputPath, variables, envOutputPathLabel) {
367
368
  const envContent = fs.readFileSync(envPath, 'utf8');
368
369
  let patchedContent = await patchEnvContentForLocal(envContent, variables);
369
- patchedContent = substituteMntDataForLocal(patchedContent, outputPath);
370
+ patchedContent = substituteMntDataForLocal(patchedContent, outputPath, variables.app?.key || path.basename(path.dirname(envPath)));
370
371
  fs.writeFileSync(outputPath, patchedContent, { mode: 0o600 });
371
372
  logger.log(formatSuccessLine(`Copied .env to: ${envOutputPathLabel}`));
372
373
  }
@@ -405,7 +406,7 @@ async function copyOrRegenerateEnvForNamedApp(opts) {
405
406
  return;
406
407
  }
407
408
  if (fs.existsSync(envPath)) {
408
- await syncWrittenBuilderEnvToOutputPath(envPath, outputPath, label, { freshEnv: freshEnv === true });
409
+ await syncWrittenBuilderEnvToOutputPath(envPath, outputPath, label, { freshEnv: freshEnv === true, appName });
409
410
  return;
410
411
  }
411
412
  await writeLocalEnvToOutputPath(outputPath, appName, secretsPath, label, localOpts);
@@ -182,7 +182,9 @@ function pickBlockingDatasourceKey(rows) {
182
182
  }
183
183
 
184
184
  function issueKey(issue) {
185
- const code = issue && issue.code ? String(issue.code) : '';
185
+ const code = issue && (issue.stableCode || issue.code)
186
+ ? String(issue.stableCode || issue.code)
187
+ : '';
186
188
  const msg = issue && issue.message ? String(issue.message) : '';
187
189
  return `${code}::${msg}`.toLowerCase();
188
190
  }
@@ -402,4 +404,3 @@ module.exports = {
402
404
  systemCertStatus,
403
405
  drillDownCommand
404
406
  };
405
-
@@ -48,7 +48,7 @@ function expandTag(entry, version) {
48
48
  * @throws {Error} On blank entries, unsupported placeholders, missing version or invalid tags
49
49
  */
50
50
  function resolveImageTags(tagInput, appVersion) {
51
- if (tagInput === undefined || tagInput === null || String(tagInput).trim() === '') {
51
+ if (tagInput === undefined || tagInput === null) {
52
52
  return [DEFAULT_TAG];
53
53
  }
54
54
  const entries = String(tagInput).split(',').map((entry) => entry.trim());
@@ -72,7 +72,7 @@ function resolveImageTags(tagInput, appVersion) {
72
72
  */
73
73
  function resolveBuildTagPlan(tagInput, appVersion) {
74
74
  const tags = resolveImageTags(tagInput, appVersion);
75
- const explicitSingle = tags.length === 1 && tagInput !== undefined && tagInput !== null && String(tagInput).trim() !== '';
75
+ const explicitSingle = tagInput !== undefined && tagInput !== null && !String(tagInput).includes(',');
76
76
  if (explicitSingle && !FLOATING_TAGS.includes(tags[0])) {
77
77
  tags.push(DEFAULT_TAG);
78
78
  }
@@ -42,7 +42,7 @@ function isPlatformCatalogKvRef(val) {
42
42
 
43
43
  /**
44
44
  * True when repair should normalize a kv value to path-style kv://systemKey/variable.
45
- * Platform catalog refs (e.g. kv://secrets-openaiApiKeyVault) are never legacy.
45
+ * Platform catalog refs (e.g. kv://redis-passwordKeyVault) are never legacy.
46
46
  *
47
47
  * @param {string} val - Value from authentication.security or configuration
48
48
  * @returns {boolean}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Prepare persistent storage before starting a local application container.
3
+ * @fileoverview Local storage mount and configured DATA_DIR initialization
4
+ */
5
+ 'use strict';
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const dotenv = require('dotenv');
10
+
11
+ /**
12
+ * Read only a DATA_DIR beneath the container's storage mount.
13
+ * @param {string|undefined} envFile - Resolved application env file
14
+ * @returns {string[]} Relative directory components
15
+ */
16
+ function dataDirectoryParts(envFile) {
17
+ if (!envFile || !fs.existsSync(envFile)) return [];
18
+ const { DATA_DIR: dataDir } = dotenv.parse(fs.readFileSync(envFile, 'utf8'));
19
+ if (!dataDir || !dataDir.startsWith('/mnt/data/')) return [];
20
+ const parts = dataDir.slice('/mnt/data/'.length).split('/').filter(part => part && part !== '.');
21
+ if (parts.some(part => part === '..' || part.includes('\\') || part.includes('\0'))) {
22
+ throw new Error('DATA_DIR must remain inside the /mnt/data storage mount');
23
+ }
24
+ return parts;
25
+ }
26
+
27
+ /**
28
+ * Create the mount root and any configured DATA_DIR inside it, preserving state.
29
+ * Refuse child symlinks so initialization cannot write outside the storage root.
30
+ * @param {string} localPath - Host directory mounted at /mnt/data
31
+ * @param {string|undefined} envFile - Resolved application env file
32
+ * @returns {void}
33
+ */
34
+ function prepareLocalDataMount(localPath, envFile) {
35
+ try {
36
+ const parts = dataDirectoryParts(envFile);
37
+ fs.mkdirSync(localPath, { recursive: true });
38
+ let directory = localPath;
39
+ for (const part of parts) {
40
+ directory = path.join(directory, part);
41
+ try {
42
+ const stat = fs.lstatSync(directory);
43
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
44
+ throw new Error('DATA_DIR components must be directories, not symlinks or files');
45
+ }
46
+ } catch (error) {
47
+ if (error.code !== 'ENOENT') throw error;
48
+ fs.mkdirSync(directory);
49
+ }
50
+ }
51
+ } catch (error) {
52
+ // Filesystem errors can include env content or paths; report only their code.
53
+ const reason = error.code || error.message;
54
+ throw new Error(`Unable to prepare local storage before container startup: ${reason}`);
55
+ }
56
+ }
57
+
58
+ module.exports = { prepareLocalDataMount };
@@ -55,7 +55,6 @@ const { resolveBashKvFromProcessEnv } = require('./secrets-bash-kv');
55
55
  const EMPTY_ALLOWED_KV_FALLBACK = new Set([
56
56
  'redis-passwordKeyVault',
57
57
  'azure-openaiapi-urlKeyVault',
58
- 'secrets-azureOpenaiApiKeyVault',
59
58
  'miso-controller-secrets-apiKeyVault'
60
59
  ]);
61
60