openxiangda 1.0.177 → 1.0.178

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/README.md CHANGED
@@ -58,9 +58,15 @@ User tokens are stored in `~/.openxiangda/profiles.json` with `0600` permissions
58
58
 
59
59
  `resource plan` and `resource publish --dry-run` run behind a strict GET/HEAD-only HTTP guard. If a read receives HTTP 401, the command fails with `READ_ONLY_AUTH_REQUIRED` and never calls the token refresh POST from inside the plan. Run `openxiangda auth refresh --profile <name>` (or log in again) before retrying; a plan must not mutate auth state or platform resources.
60
60
 
61
+ For Function/Automation plans, `formFieldContracts` checks statically declared
62
+ Form filter/order fields against frozen online Form schemas. A missing binding
63
+ or field makes publish fail before lease/write; dynamic field names are checked
64
+ again by the platform before SQL and return `FORM_FIELD_NOT_FOUND` instead of a
65
+ database-column error.
66
+
61
67
  React SPA workspaces publish their frontend with `openxiangda runtime deploy`. Every finalized Runtime release is built from a clean, committed Git `HEAD`; the CLI freezes `sourceRevision`, the current active release, and its source revision before any build/upload. Deploy fails with `RUNTIME_SOURCE_BASE_DIVERGED` when its `HEAD` does not descend from the online Runtime source, including with `--no-activate`, so an old isolated worktree cannot stage and later activate a silent rollback. `.openxiangda/`, `openspec/`, `dist/`, and other pure generated/governance/state paths do not make the source dirty, but `--no-build` cannot bypass the lineage gate. An intentional rollback requires `--allow-runtime-rollback --reason "<at least 8 characters>"`, which is persisted for audit and never bypasses dirty/non-Git checks.
62
68
 
63
- Parallel tasks develop and test in isolated worktrees, but feature worktrees do not publish. After approved commits are merged and pushed, `sdd bundle <release-change> --changes ...` unions their exact structured scope. Commit/push the bundle, then run `release publish --change <release-change> --profile <name>` from a clean local main/master whose commit exactly equals the authoritative remote tip. It verifies without rewriting reviewed SDD files, waits for the app lease, freezes one authoritative App capture, executes exact Form/Backend/Runtime staged steps, atomically finalizes the Root App release, and records a resumable local execution journal. Runtime checks use the narrow head endpoint; immutable Git-base artifact hashes are reused across plans.
69
+ Parallel tasks develop and test in isolated worktrees, but feature worktrees do not publish. After approved commits are merged and pushed, `sdd bundle <release-change> --changes ...` unions their exact structured scope and preserves the source changes' common Git baseline instead of adopting the post-merge `HEAD`. Old imported changes that predate source-base metadata must pass `--source-base-ref <commit>` explicitly. Commit/push the bundle, then run `release publish --change <release-change> --profile <name>` from a clean local main/master whose commit exactly equals the authoritative remote tip. It verifies without rewriting reviewed SDD files, waits for the app lease, freezes one authoritative App capture, executes exact Form/Backend/Runtime staged steps, atomically finalizes the Root App release, and records a resumable local execution journal. Runtime checks use the narrow head endpoint; immutable Git-base artifact hashes are reused across plans.
64
70
 
65
71
  Because promotion starts from an already-pushed authoritative mainline commit, `openxiangda release integration-status --profile <name>` should pass immediately after activation. Run it and `release end`; there is no post-release merge step.
66
72
 
package/lib/cli.js CHANGED
@@ -82,6 +82,10 @@ const {
82
82
  writeContentCache,
83
83
  } = require('./content-cache');
84
84
  const { explainReleaseExecution } = require('./release-explain');
85
+ const {
86
+ classifyReleaseExecutionFailure,
87
+ isDefinitelyPreWriteReleaseError,
88
+ } = require('./release-error-classification');
85
89
  const { buildTaskStatus, findAppReleaseId } = require('./task-status');
86
90
  const {
87
91
  checkEngineeringPolicyMarkers,
@@ -94,6 +98,10 @@ const {
94
98
  getImpactedResourceTargets,
95
99
  validateManifestSourceBindings,
96
100
  } = require('./source-dependencies');
101
+ const {
102
+ collectFormSnapshotFieldIds,
103
+ validateFormFieldContracts,
104
+ } = require('./form-field-contract');
97
105
  const { getSkillStatusReport, installSkills } = require('./skills');
98
106
  const {
99
107
  assertOrClaimWorktreeOwner,
@@ -705,7 +713,7 @@ async function sdd(args) {
705
713
  ' openxiangda sdd quick tune-customer-copy --kind copy --pages customer_list --files src/pages/customer_list/index.tsx --summary "用户确认小范围文案调整"',
706
714
  ' openxiangda sdd approve add-customer-list --summary "用户确认设计"',
707
715
  ' openxiangda sdd ready add-customer-list',
708
- ' openxiangda sdd bundle mainline-release --changes add-customer-list,fix-order-log',
716
+ ' openxiangda sdd bundle mainline-release --changes add-customer-list,fix-order-log [--source-base-ref <commit>]',
709
717
  ' openxiangda sdd context --change add-customer-list --json',
710
718
  ' openxiangda sdd verify --change add-customer-list --stage prepublish --json',
711
719
  ' openxiangda sdd render add-customer-list # 仅在确实需要完整文档时',
@@ -720,6 +728,7 @@ async function sdd(args) {
720
728
  ' - verify stage 可选 implementation、prepublish、postpublish、archive;不传保持旧版全量校验。',
721
729
  ' - 默认 streamlined 校验只把 approval、精确资源/文件范围、实际发布命令作为硬门禁;任务、证据和规格文案仅告警。配置 strictDocumentation: true 可恢复严格文档门禁。',
722
730
  ' - bundle 聚合多个 approved change;提交并推送该 bundle 后,在权威主分支执行一次 staged + atomic 发布。',
731
+ ' - bundle 默认保留来源 change 创建时的共同 Git 基线;仅迁移旧 change 缺少 sourceBase 时使用 --source-base-ref 显式补充。',
723
732
  ' - 新 release 只能从已推送且与权威远端默认主分支一致的 clean HEAD 开始。',
724
733
  ].join('\n'));
725
734
  return;
@@ -800,6 +809,7 @@ async function sdd(args) {
800
809
  changes: flags.changes,
801
810
  integration,
802
811
  profile: flags.profile,
812
+ sourceBaseRef: flags['source-base-ref'],
803
813
  force: Boolean(flags.force),
804
814
  });
805
815
  if (flags.json) return writeJson(result);
@@ -824,6 +834,7 @@ async function sdd(args) {
824
834
  changeId,
825
835
  targets: scope.targets,
826
836
  changedFiles: scope.changedFiles,
837
+ sourceBase: scope.change?.sourceBase,
827
838
  });
828
839
  const ownerStatus = getWorktreeOwnerStatus({ cwd: process.cwd() });
829
840
  if (
@@ -1138,30 +1149,6 @@ function releaseExecutionContextFromBegin(result, target, changeId) {
1138
1149
  };
1139
1150
  }
1140
1151
 
1141
- function isDefinitelyPreWriteReleaseError(error) {
1142
- const code = String(error?.code || '');
1143
- if (new Set([
1144
- 'RELEASE_PUBLISH_REVISION_CHANGED',
1145
- 'RELEASE_SOURCE_DIRTY',
1146
- 'RELEASE_SOURCE_BRANCH_REQUIRED',
1147
- 'RELEASE_SOURCE_MAINLINE_REQUIRED',
1148
- 'RELEASE_SOURCE_MAINLINE_NOT_PUSHED',
1149
- 'RELEASE_SOURCE_BEHIND_MAIN',
1150
- 'RELEASE_GIT_AUTH_REQUIRED',
1151
- 'RELEASE_MAIN_BRANCH_UNRESOLVED',
1152
- 'RELEASE_MAIN_REF_UNVERIFIED',
1153
- 'SDD_PREPUBLISH_FAILED',
1154
- 'PUBLISH_CONTEXT_REQUIRED',
1155
- 'APP_RELEASE_STAGED_SCOPE_REQUIRED',
1156
- 'APP_RELEASE_STAGED_SCOPE_MISMATCH',
1157
- ]).has(code)) {
1158
- return true;
1159
- }
1160
- return /^(?:SDD_|RELEASE_(?:SOURCE|GIT|MAINLINE|MAIN_BRANCH|MAIN_REF)|RUNTIME_(?:SOURCE|BUILD|PACKAGE|DEPENDENCY))/.test(
1161
- code
1162
- );
1163
- }
1164
-
1165
1152
  function parseChildJsonOutput(stdout) {
1166
1153
  const text = String(stdout || '').trim();
1167
1154
  if (!text) return null;
@@ -1691,11 +1678,11 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1691
1678
  execution.writeAttempted = true;
1692
1679
  }
1693
1680
  }
1694
- execution.status = execution.stagedWriteOccurred
1695
- ? 'staged-resumable'
1696
- : execution.writeAttempted
1697
- ? 'write-review-required'
1698
- : 'failed';
1681
+ execution.status = classifyReleaseExecutionFailure({
1682
+ error,
1683
+ stagedWriteOccurred: execution.stagedWriteOccurred,
1684
+ writeAttempted: execution.writeAttempted,
1685
+ });
1699
1686
  execution.updatedAt = new Date().toISOString();
1700
1687
  writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1701
1688
  if (!execution.writeAttempted) {
@@ -3822,9 +3809,25 @@ function resolveChangeSourceBase(changeId, flags = {}) {
3822
3809
  if (!explicitBaseRef && changeId) {
3823
3810
  try {
3824
3811
  const scope = getSddChangeScope({ cwd: process.cwd(), changeId });
3825
- const recorded = scope?.change?.sourceBase;
3812
+ const recorded =
3813
+ scope?.change?.sourceBase ||
3814
+ scope?.release?.sourceBase;
3826
3815
  if (recorded?.baseCommit && recorded?.treeHash) return recorded;
3827
- } catch {
3816
+ const recordedBaseRevision =
3817
+ scope?.change?.baseRevision ||
3818
+ scope?.release?.baseRevision;
3819
+ if (recordedBaseRevision) {
3820
+ return readGitSourceBase(process.cwd(), recordedBaseRevision);
3821
+ }
3822
+ if (scope?.change?.changeMode === 'mainline-bundle') {
3823
+ const error = new Error(
3824
+ `SDD_SOURCE_BASE_REQUIRED: mainline bundle ${changeId} 缺少 sourceBase/baseRevision,禁止退化为当前 HEAD`
3825
+ );
3826
+ error.code = 'SDD_SOURCE_BASE_REQUIRED';
3827
+ throw error;
3828
+ }
3829
+ } catch (error) {
3830
+ if (error?.code === 'SDD_SOURCE_BASE_REQUIRED') throw error;
3828
3831
  // A direct release may use --change without a local SDD record. In that
3829
3832
  // case the current Git HEAD is still a deterministic, auditable base.
3830
3833
  }
@@ -10494,6 +10497,18 @@ async function resource(args) {
10494
10497
  error.missingSecretRefs = missing;
10495
10498
  throw error;
10496
10499
  }
10500
+ if (publishPlan.formFieldContracts?.valid === false) {
10501
+ const violations = publishPlan.formFieldContracts.errors || [];
10502
+ const error = new Error(
10503
+ `FORM_FIELD_CONTRACT_INVALID: ${violations
10504
+ .slice(0, 8)
10505
+ .map(item => item.message)
10506
+ .join('; ')}`
10507
+ );
10508
+ error.code = 'FORM_FIELD_CONTRACT_INVALID';
10509
+ error.violations = violations;
10510
+ throw error;
10511
+ }
10497
10512
  const pruneCandidates = flags.prune
10498
10513
  ? await telemetry.runPhase('prune-plan', async () =>
10499
10514
  collectPruneResourceCandidates(config, target, manifest)
@@ -15053,6 +15068,18 @@ async function publishResourcesForWorkspace(config, profileName, options = {}) {
15053
15068
  }
15054
15069
  const target = getWorkspaceTarget(config, profileName, {});
15055
15070
  const plan = await buildResourcePlan(config, target, manifest);
15071
+ if (plan.formFieldContracts?.valid === false) {
15072
+ const violations = plan.formFieldContracts.errors || [];
15073
+ const error = new Error(
15074
+ `FORM_FIELD_CONTRACT_INVALID: ${violations
15075
+ .slice(0, 8)
15076
+ .map(item => item.message)
15077
+ .join('; ')}`
15078
+ );
15079
+ error.code = 'FORM_FIELD_CONTRACT_INVALID';
15080
+ error.violations = violations;
15081
+ throw error;
15082
+ }
15056
15083
  const pruneCandidates = options.prune
15057
15084
  ? await collectPruneResourceCandidates(config, target, manifest)
15058
15085
  : [];
@@ -15088,7 +15115,10 @@ async function buildResourcePlan(config, target, manifest) {
15088
15115
  manifest
15089
15116
  );
15090
15117
  await prepareManifestJsCodeBundlesForPlan(manifest);
15091
- const existing = await fetchExistingResourceMaps(config, target, manifest);
15118
+ const [existing, formFieldContracts] = await Promise.all([
15119
+ fetchExistingResourceMaps(config, target, manifest),
15120
+ buildFormFieldContractPlan(config, target, manifest),
15121
+ ]);
15092
15122
  const actions = [];
15093
15123
  addPlanActions(actions, 'role', manifest.roles, existing.roles, roleEquals);
15094
15124
  addPlanActions(actions, 'menu', manifest.menus, existing.menus, (item, current) => menuEquals(target.bound, item, current));
@@ -15128,6 +15158,7 @@ async function buildResourcePlan(config, target, manifest) {
15128
15158
  ...(manifest.resourceCodeFilters ? { resourceCodeFilters: manifest.resourceCodeFilters } : {}),
15129
15159
  actions,
15130
15160
  summary: summarizeActions(actions),
15161
+ formFieldContracts,
15131
15162
  ...(functionSecretRefs
15132
15163
  ? {
15133
15164
  capabilities: {
@@ -16518,7 +16549,14 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
16518
16549
  }
16519
16550
 
16520
16551
  await publishRoleResources(config, target, manifest.roles || [], result, publishOptions);
16521
- await publishFormSettingsResources(config, target, manifest.formSettings || [], result, publishOptions);
16552
+ await publishFormSettingsResources(
16553
+ config,
16554
+ target,
16555
+ manifest.formSettings || [],
16556
+ manifest.formPermissionGroups || [],
16557
+ result,
16558
+ publishOptions
16559
+ );
16522
16560
  await publishMenuResources(config, target, manifest.menus || [], result, publishOptions);
16523
16561
  await publishConnectorResources(config, target, manifest.connectors || [], result, publishOptions);
16524
16562
  await publishNotificationResources(config, target, manifest.notifications || [], result, publishOptions);
@@ -16548,7 +16586,13 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
16548
16586
  await publishStorageConfigResources(config, target, manifest.storageConfigs || [], result, publishOptions);
16549
16587
  await publishPublicAccessPolicyResources(config, target, manifest.publicAccessPolicies || [], result, publishOptions);
16550
16588
  await publishPagePermissionGroupResources(config, target, manifest.pagePermissionGroups || [], result, publishOptions);
16551
- await publishFormPermissionGroupResources(config, target, manifest.formPermissionGroups || [], result, publishOptions);
16589
+ await publishFormPermissionGroupResources(
16590
+ config,
16591
+ target,
16592
+ manifest.formPermissionGroups || [],
16593
+ result,
16594
+ publishOptions
16595
+ );
16552
16596
  if (options.prune) {
16553
16597
  await pruneResourceManifest(
16554
16598
  config,
@@ -17939,22 +17983,82 @@ async function publishFormSettingsResources(
17939
17983
  config,
17940
17984
  target,
17941
17985
  settingsItems,
17986
+ permissionGroups,
17942
17987
  result,
17943
17988
  options = {}
17944
17989
  ) {
17990
+ const settingsByForm = new Map();
17945
17991
  for (const item of settingsItems) {
17946
17992
  const code = item.code || item.formCode || item.formUuid;
17947
- if (shouldSkipNoopResource(options, 'formSetting', code)) {
17948
- recordNoopResource(result, 'formSetting', code, options);
17993
+ const formUuid = resolveManifestFormUuid(target.bound, item, {
17994
+ fallbackToCode: true,
17995
+ });
17996
+ if (!formUuid) fail(`表单设置 ${code} 无法解析 formUuid`);
17997
+ settingsByForm.set(formUuid, { item, code });
17998
+ }
17999
+ const changedGroupsByForm = new Map();
18000
+ options.formReleaseHandledPermissionCodes =
18001
+ options.formReleaseHandledPermissionCodes || new Set();
18002
+ for (const group of permissionGroups || []) {
18003
+ const code = group.code || group.resourceCode;
18004
+ if (shouldSkipNoopResource(options, 'formPermissionGroup', code)) {
18005
+ recordNoopResource(result, 'formPermissionGroup', code, options);
18006
+ options.formReleaseHandledPermissionCodes.add(code);
17949
18007
  continue;
17950
18008
  }
17951
- const formUuid = resolveManifestFormUuid(target.bound, item, { fallbackToCode: true });
17952
- if (!formUuid) fail(`表单设置 ${code} 无法解析 formUuid`);
17953
- const action = getPlannedResourceAction(options, 'formSetting', code);
18009
+ const formUuid = resolveManifestFormUuid(target.bound, group);
18010
+ if (!formUuid) {
18011
+ fail(`表单权限组 ${code} 无法解析 formUuid,不能进入 FormRelease`);
18012
+ }
18013
+ if (!changedGroupsByForm.has(formUuid)) {
18014
+ changedGroupsByForm.set(formUuid, []);
18015
+ }
18016
+ changedGroupsByForm.get(formUuid).push(group);
18017
+ }
18018
+ const formUuids = unique([
18019
+ ...settingsByForm.keys(),
18020
+ ...changedGroupsByForm.keys(),
18021
+ ]).sort();
18022
+ for (const formUuid of formUuids) {
18023
+ const setting = settingsByForm.get(formUuid);
18024
+ const item = setting?.item || null;
18025
+ const groups = changedGroupsByForm.get(formUuid) || [];
18026
+ const code =
18027
+ setting?.code ||
18028
+ groups[0]?.formCode ||
18029
+ groups[0]?.form ||
18030
+ formUuid;
18031
+ const settingNoop =
18032
+ item && shouldSkipNoopResource(options, 'formSetting', code);
18033
+ if (settingNoop) {
18034
+ recordNoopResource(result, 'formSetting', code, options);
18035
+ }
18036
+ if (settingNoop && groups.length === 0) continue;
18037
+ const action = item
18038
+ ? getPlannedResourceAction(options, 'formSetting', code)
18039
+ : null;
17954
18040
  const frozen =
17955
18041
  frozenFormConfigFromPlanAction(action) ||
17956
18042
  (await fetchFrozenFormConfig(config, target, formUuid));
17957
- const bundleBody = buildFormSettingBundleBody(item);
18043
+ const bundleBody =
18044
+ item && !settingNoop ? buildFormSettingBundleBody(item) : {};
18045
+ if (groups.length > 0) {
18046
+ bundleBody.permissionGroups = groups.map(group => {
18047
+ const body = {
18048
+ ...withoutResourceMeta(group),
18049
+ resourceCode: group.code || group.resourceCode,
18050
+ name: group.name || group.code || group.resourceCode,
18051
+ type: group.type || 'view',
18052
+ roles: group.roles || [],
18053
+ };
18054
+ delete body.code;
18055
+ delete body.formCode;
18056
+ delete body.form;
18057
+ delete body.formUuid;
18058
+ return body;
18059
+ });
18060
+ bundleBody.permissionGroupsMode = 'merge';
18061
+ }
17958
18062
  const activate = options.activateFormBundles === true;
17959
18063
  const artifactId = stableFormArtifactId(
17960
18064
  'config-bundle',
@@ -17975,7 +18079,19 @@ async function publishFormSettingsResources(
17975
18079
  },
17976
18080
  { frozen, artifactId }
17977
18081
  );
17978
- saveResourceEntry(target, 'formSettings', code, { formUuid });
18082
+ if (item) {
18083
+ saveResourceEntry(target, 'formSettings', code, { formUuid });
18084
+ }
18085
+ for (const group of groups) {
18086
+ const groupCode = group.code || group.resourceCode;
18087
+ saveFormPermissionGroupResource(
18088
+ target,
18089
+ groupCode,
18090
+ undefined,
18091
+ { formUuid }
18092
+ );
18093
+ options.formReleaseHandledPermissionCodes.add(groupCode);
18094
+ }
17979
18095
  const releaseId = data?.release?.id;
17980
18096
  const contentHash = String(
17981
18097
  data?.release?.contentHash || data?.contentHash || ''
@@ -18024,20 +18140,36 @@ async function publishFormSettingsResources(
18024
18140
  delete result.stagedResource;
18025
18141
  }
18026
18142
  }
18027
- result.published.push({
18028
- kind: 'formSetting',
18029
- code,
18030
- action: data?.noop ? 'noop' : data?.active ? 'update' : 'stage',
18031
- formUuid,
18032
- revision: data?.revision,
18033
- activeFormReleaseHead: data?.activeFormReleaseHead,
18034
- staged: Boolean(data?.staged),
18035
- active: Boolean(data?.active),
18036
- releaseStatus: data?.releaseStatus,
18037
- releaseId,
18038
- contentHash: contentHash || undefined,
18039
- ...(stagedResource ? { stagedResource } : {}),
18040
- });
18143
+ if (item && !settingNoop) {
18144
+ result.published.push({
18145
+ kind: 'formSetting',
18146
+ code,
18147
+ action: data?.noop ? 'noop' : data?.active ? 'update' : 'stage',
18148
+ formUuid,
18149
+ revision: data?.revision,
18150
+ activeFormReleaseHead: data?.activeFormReleaseHead,
18151
+ staged: Boolean(data?.staged),
18152
+ active: Boolean(data?.active),
18153
+ releaseStatus: data?.releaseStatus,
18154
+ releaseId,
18155
+ contentHash: contentHash || undefined,
18156
+ ...(stagedResource ? { stagedResource } : {}),
18157
+ });
18158
+ }
18159
+ for (const group of groups) {
18160
+ result.published.push({
18161
+ kind: 'formPermissionGroup',
18162
+ code: group.code || group.resourceCode,
18163
+ action: data?.noop ? 'noop' : data?.active ? 'update' : 'stage',
18164
+ formUuid,
18165
+ staged: Boolean(data?.staged),
18166
+ active: Boolean(data?.active),
18167
+ releaseStatus: data?.releaseStatus,
18168
+ releaseId,
18169
+ contentHash: contentHash || undefined,
18170
+ ...(stagedResource ? { stagedResource } : {}),
18171
+ });
18172
+ }
18041
18173
  }
18042
18174
  }
18043
18175
 
@@ -19158,38 +19290,24 @@ async function findExistingPagePermissionGroup(config, target, code) {
19158
19290
 
19159
19291
  async function publishFormPermissionGroupResources(config, target, groups, result, options = {}) {
19160
19292
  for (const group of groups) {
19293
+ if (
19294
+ options.formReleaseHandledPermissionCodes?.has(
19295
+ group.code || group.resourceCode
19296
+ )
19297
+ ) {
19298
+ continue;
19299
+ }
19161
19300
  if (shouldSkipNoopResource(options, 'formPermissionGroup', group.code)) {
19162
19301
  recordNoopResource(result, 'formPermissionGroup', group.code, options);
19163
19302
  continue;
19164
19303
  }
19165
- const formUuid = resolveManifestFormUuid(target.bound, group);
19166
- const existing = await findExistingFormPermissionGroup(config, target, group.code, formUuid);
19167
- const body = {
19168
- ...withoutResourceMeta(group),
19169
- resourceCode: group.code,
19170
- formUuid,
19171
- name: group.name || group.code,
19172
- type: group.type || 'view',
19173
- roles: group.roles || [],
19174
- };
19175
- delete body.code;
19176
- delete body.formCode;
19177
- delete body.form;
19178
- const data = existing
19179
- ? await requestWithAuth(
19180
- config,
19181
- target.profileName,
19182
- `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms/${encodeURIComponent(formUuid)}/permission-groups/${encodeURIComponent(existing.id)}`,
19183
- { method: 'POST', body }
19184
- )
19185
- : await requestWithAuth(
19186
- config,
19187
- target.profileName,
19188
- `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms/${encodeURIComponent(formUuid)}/permission-groups`,
19189
- { method: 'POST', body }
19190
- );
19191
- if (data?.id) saveFormPermissionGroupResource(target, group.code, data.id, { formUuid, name: data.name, resourceCode: group.code });
19192
- result.published.push({ kind: 'formPermissionGroup', code: group.code, action: existing ? 'update' : 'create', id: data?.id });
19304
+ const error = new Error(
19305
+ `FORM_PERMISSION_GROUP_STAGE_REQUIRED: ${
19306
+ group.code || group.resourceCode
19307
+ } 未进入 FormRelease,禁止回退为直接线上写入`
19308
+ );
19309
+ error.code = 'FORM_PERMISSION_GROUP_STAGE_REQUIRED';
19310
+ throw error;
19193
19311
  }
19194
19312
  }
19195
19313
 
@@ -24233,8 +24351,104 @@ async function runWorkspaceChildCommand(command, args, options = {}) {
24233
24351
  error.code = options.failureCode || 'WORKSPACE_CHILD_COMMAND_FAILED';
24234
24352
  error.exitCode = Number.isInteger(code) ? code : 1;
24235
24353
  reject(error);
24236
- });
24237
24354
  });
24355
+ });
24356
+ }
24357
+
24358
+ async function buildFormFieldContractPlan(config, target, manifest) {
24359
+ const targets = [];
24360
+ for (const [manifestKey, kind] of [
24361
+ ['functions', 'Function'],
24362
+ ['automations', 'Automation'],
24363
+ ['workflows', 'Workflow'],
24364
+ ['jsCodeNodes', 'JsCodeNode'],
24365
+ ]) {
24366
+ for (const item of manifest[manifestKey] || []) {
24367
+ const analysis = item.__sourceAnalysis;
24368
+ if (!analysis?.formFieldReferences?.length) continue;
24369
+ const code =
24370
+ item.code ||
24371
+ item.functionCode ||
24372
+ item.resourceCode ||
24373
+ item.scriptCode;
24374
+ for (const reference of analysis.formFieldReferences) {
24375
+ const formUuid = resolveManifestFormBindingForContract(
24376
+ target,
24377
+ item,
24378
+ reference.formCode
24379
+ );
24380
+ targets.push({
24381
+ kind,
24382
+ code,
24383
+ formCode: reference.formCode,
24384
+ formUuid,
24385
+ field: reference.field,
24386
+ api: reference.api,
24387
+ file: reference.file,
24388
+ line: reference.line,
24389
+ });
24390
+ }
24391
+ }
24392
+ }
24393
+ if (targets.length === 0) {
24394
+ return {
24395
+ contractVersion: 'form_field_contract_v1',
24396
+ valid: true,
24397
+ checkedForms: 0,
24398
+ checkedReferences: 0,
24399
+ errors: [],
24400
+ };
24401
+ }
24402
+ const snapshots = new Map();
24403
+ const formTargets = unique(
24404
+ targets.map(item => item.formUuid).filter(Boolean)
24405
+ ).sort();
24406
+ await Promise.all(
24407
+ formTargets.map(async formUuid => {
24408
+ const frozen = await fetchFrozenFormConfig(
24409
+ config,
24410
+ target,
24411
+ formUuid
24412
+ );
24413
+ snapshots.set(formUuid, collectFormSnapshotFieldIds(frozen.snapshot));
24414
+ })
24415
+ );
24416
+ return validateFormFieldContracts(targets, snapshots);
24417
+ }
24418
+
24419
+ function resolveManifestFormBindingForContract(target, item, formCode) {
24420
+ const containers = [
24421
+ item.resourceBindings,
24422
+ item.resources,
24423
+ item.definitionJson?.resourceBindings,
24424
+ item.definitionJson?.resources,
24425
+ ];
24426
+ if (item.definitionFile) {
24427
+ try {
24428
+ const definition = JSON.parse(
24429
+ fs.readFileSync(
24430
+ path.resolve(item.__dir || process.cwd(), item.definitionFile),
24431
+ 'utf8'
24432
+ )
24433
+ );
24434
+ containers.push(definition.resourceBindings, definition.resources);
24435
+ } catch {
24436
+ // Resource validation reports invalid definition files separately.
24437
+ }
24438
+ }
24439
+ for (const container of containers) {
24440
+ const binding = container?.forms?.[formCode];
24441
+ if (typeof binding === 'string' && binding.trim()) return binding.trim();
24442
+ const formUuid = String(
24443
+ binding?.formUuid || binding?.id || binding?.uuid || ''
24444
+ ).trim();
24445
+ if (formUuid) return formUuid;
24446
+ }
24447
+ return resolveManifestFormUuid(
24448
+ target.bound,
24449
+ { formCode },
24450
+ { fallbackToCode: false }
24451
+ );
24238
24452
  }
24239
24453
 
24240
24454
  function buildWorkspacePublishEnv(profileName, profile, appType, globalEnv) {
@@ -0,0 +1,79 @@
1
+ const FORM_QUERY_SYSTEM_FIELDS = new Set([
2
+ 'approvalResult',
3
+ 'createTime',
4
+ 'currentApprovalNodeName',
5
+ 'modifiedTime',
6
+ 'originator',
7
+ 'originatorCorp',
8
+ 'originatorName',
9
+ 'processInstanceId',
10
+ 'processInstanceStatus',
11
+ 'processInstanceTitle',
12
+ ]);
13
+
14
+ function collectFormSnapshotFieldIds(snapshot) {
15
+ const fields = new Set();
16
+ const formFields = snapshot?.form?.formFields || {};
17
+ for (const [key, value] of Object.entries(formFields)) {
18
+ if (key) fields.add(String(key));
19
+ for (const candidate of [value?.fieldId, value?.field_id]) {
20
+ if (candidate) fields.add(String(candidate));
21
+ }
22
+ }
23
+ const visit = value => {
24
+ if (!value || typeof value !== 'object') return;
25
+ if (Array.isArray(value)) {
26
+ value.forEach(visit);
27
+ return;
28
+ }
29
+ const fieldId = value.fieldId || value.props?.fieldId;
30
+ if (fieldId) fields.add(String(fieldId));
31
+ Object.values(value).forEach(visit);
32
+ };
33
+ visit(snapshot?.form?.schema);
34
+ return fields;
35
+ }
36
+
37
+ function validateFormFieldContracts(targets, snapshots) {
38
+ const references = Array.isArray(targets) ? targets : [];
39
+ const snapshotFields =
40
+ snapshots instanceof Map ? snapshots : new Map(Object.entries(snapshots || {}));
41
+ const errors = [];
42
+ for (const item of references) {
43
+ const { code: resourceCode, ...detail } = item;
44
+ if (!item.formUuid) {
45
+ errors.push({
46
+ code: 'FORM_FIELD_CONTRACT_BINDING_MISSING',
47
+ resourceCode,
48
+ ...detail,
49
+ message: `${item.kind}:${resourceCode} ${item.file}:${item.line} 查询 ${item.formCode}.${item.field},但无法解析该表单的 profile 绑定`,
50
+ });
51
+ continue;
52
+ }
53
+ if (
54
+ FORM_QUERY_SYSTEM_FIELDS.has(item.field) ||
55
+ snapshotFields.get(item.formUuid)?.has(item.field)
56
+ ) {
57
+ continue;
58
+ }
59
+ errors.push({
60
+ code: 'FORM_FIELD_CONTRACT_FIELD_MISSING',
61
+ resourceCode,
62
+ ...detail,
63
+ message: `${item.kind}:${resourceCode} ${item.file}:${item.line} 通过 ${item.api} 查询 ${item.formCode}.${item.field},但线上 Form ${item.formUuid} 不存在该字段`,
64
+ });
65
+ }
66
+ return {
67
+ contractVersion: 'form_field_contract_v1',
68
+ valid: errors.length === 0,
69
+ checkedForms: new Set(references.map(item => item.formUuid).filter(Boolean)).size,
70
+ checkedReferences: references.length,
71
+ errors,
72
+ };
73
+ }
74
+
75
+ module.exports = {
76
+ FORM_QUERY_SYSTEM_FIELDS,
77
+ collectFormSnapshotFieldIds,
78
+ validateFormFieldContracts,
79
+ };
@@ -2,7 +2,8 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { spawnSync } = require('child_process');
4
4
 
5
- const TASK_RESULT_SCHEMA_VERSION = 'openxiangda_task_result_v1';
5
+ const TASK_RESULT_SCHEMA_VERSION = 'openxiangda_task_result_v2';
6
+ const LEGACY_TASK_RESULT_SCHEMA_VERSION = 'openxiangda_task_result_v1';
6
7
  const INTEGRATION_BUNDLE_SCHEMA_VERSION = 'openxiangda_integration_bundle_v1';
7
8
 
8
9
  function runGit(cwd, args, options = {}) {
@@ -77,6 +78,16 @@ function recordIntegrationTaskResult(options = {}) {
77
78
  commit,
78
79
  treeHash,
79
80
  branch,
81
+ ...(options.sourceBase?.baseCommit && options.sourceBase?.treeHash
82
+ ? {
83
+ sourceBase: {
84
+ repo: options.sourceBase.repo,
85
+ baseCommit: options.sourceBase.baseCommit,
86
+ treeHash: options.sourceBase.treeHash,
87
+ },
88
+ baseRevision: options.sourceBase.baseCommit,
89
+ }
90
+ : {}),
80
91
  targets: options.targets || {},
81
92
  changedFiles: Array.from(
82
93
  new Set((options.changedFiles || []).map(String).filter(Boolean))
@@ -96,7 +107,10 @@ function readIntegrationTaskResult(cwd, changeId) {
96
107
  if (!fs.existsSync(file)) return null;
97
108
  try {
98
109
  const value = JSON.parse(fs.readFileSync(file, 'utf8'));
99
- return value?.schemaVersion === TASK_RESULT_SCHEMA_VERSION
110
+ return [
111
+ TASK_RESULT_SCHEMA_VERSION,
112
+ LEGACY_TASK_RESULT_SCHEMA_VERSION,
113
+ ].includes(value?.schemaVersion)
100
114
  ? { ...value, file }
101
115
  : null;
102
116
  } catch {
@@ -146,6 +160,13 @@ function collectIntegrationTaskResults(options = {}) {
146
160
  commit: result.commit,
147
161
  treeHash: result.treeHash,
148
162
  sourceBranch: result.branch,
163
+ ...(result.sourceBase?.baseCommit && result.sourceBase?.treeHash
164
+ ? {
165
+ sourceBase: result.sourceBase,
166
+ baseRevision:
167
+ result.baseRevision || result.sourceBase.baseCommit,
168
+ }
169
+ : {}),
149
170
  })),
150
171
  verifiedAgainst: runGit(cwd, ['rev-parse', 'HEAD']).stdout,
151
172
  createdAt: new Date().toISOString(),
@@ -0,0 +1,41 @@
1
+ const DEFINITELY_PRE_WRITE_CODES = new Set([
2
+ 'APP_RELEASE_STAGED_SCOPE_MISMATCH',
3
+ 'APP_RELEASE_STAGED_SCOPE_REQUIRED',
4
+ 'BASELINE_SCOPE_MISSING',
5
+ 'FORM_FIELD_CONTRACT_INVALID',
6
+ 'PUBLISH_CONTEXT_REQUIRED',
7
+ 'RELEASE_GIT_AUTH_REQUIRED',
8
+ 'RELEASE_MAIN_BRANCH_UNRESOLVED',
9
+ 'RELEASE_MAIN_REF_UNVERIFIED',
10
+ 'RELEASE_PUBLISH_REVISION_CHANGED',
11
+ 'RELEASE_SOURCE_BEHIND_MAIN',
12
+ 'RELEASE_SOURCE_BRANCH_REQUIRED',
13
+ 'RELEASE_SOURCE_DIRTY',
14
+ 'RELEASE_SOURCE_MAINLINE_NOT_PUSHED',
15
+ 'RELEASE_SOURCE_MAINLINE_REQUIRED',
16
+ 'RESOURCE_FIELD_CONFLICT',
17
+ 'SDD_PREPUBLISH_FAILED',
18
+ 'SOURCE_BASE_DIVERGED',
19
+ ]);
20
+
21
+ function isDefinitelyPreWriteReleaseError(error) {
22
+ const code = String(error?.code || '').trim();
23
+ if (DEFINITELY_PRE_WRITE_CODES.has(code)) return true;
24
+ return /^(?:SDD_|RELEASE_(?:SOURCE|GIT|MAINLINE|MAIN_BRANCH|MAIN_REF)|RUNTIME_(?:SOURCE|BUILD|PACKAGE|DEPENDENCY))/.test(
25
+ code
26
+ );
27
+ }
28
+
29
+ function classifyReleaseExecutionFailure({
30
+ stagedWriteOccurred = false,
31
+ writeAttempted = false,
32
+ } = {}) {
33
+ if (stagedWriteOccurred) return 'staged-resumable';
34
+ if (writeAttempted) return 'write-review-required';
35
+ return 'failed';
36
+ }
37
+
38
+ module.exports = {
39
+ classifyReleaseExecutionFailure,
40
+ isDefinitelyPreWriteReleaseError,
41
+ };
package/lib/sdd.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
+ const { spawnSync } = require('child_process');
4
5
  const { readGitSourceBase } = require('./change-baseline');
5
6
  const { assertReleaseSourceIntegrated } = require('./release-mainline');
6
7
  const {
@@ -604,6 +605,7 @@ function createChangeMetadata(changeId, options = {}) {
604
605
  baseCommit: sourceBase.baseCommit,
605
606
  treeHash: sourceBase.treeHash,
606
607
  };
608
+ metadata.baseRevision = sourceBase.baseCommit;
607
609
  }
608
610
  } catch {
609
611
  // SDD can still be used before a workspace is initialized as a Git repo.
@@ -1013,6 +1015,30 @@ function createMainlineSddBundle(options = {}) {
1013
1015
  if (nested) {
1014
1016
  throw new Error(`mainline bundle 不允许嵌套: ${nested.changeId}`);
1015
1017
  }
1018
+ const bundleSourceBase = resolveMainlineBundleSourceBase(
1019
+ sourceChanges,
1020
+ options
1021
+ );
1022
+ const integratedChanges = sourceChanges.map(item => {
1023
+ const sourceBase =
1024
+ normalizeSddSourceBase(item.meta.sourceBase) || bundleSourceBase;
1025
+ const task = options.integration?.requiredCommits?.find(
1026
+ candidate => candidate.changeId === item.changeId
1027
+ );
1028
+ return {
1029
+ changeId: item.changeId,
1030
+ sourceBase,
1031
+ baseRevision:
1032
+ item.meta.baseRevision || sourceBase.baseCommit,
1033
+ ...(task
1034
+ ? {
1035
+ commit: task.commit,
1036
+ treeHash: task.treeHash,
1037
+ sourceBranch: task.sourceBranch,
1038
+ }
1039
+ : {}),
1040
+ };
1041
+ });
1016
1042
 
1017
1043
  const affected = mergeAffectedScopes(
1018
1044
  ...sourceChanges.flatMap(item => [
@@ -1041,16 +1067,19 @@ function createMainlineSddBundle(options = {}) {
1041
1067
  const createdAt = nowIso();
1042
1068
  const meta = {
1043
1069
  ...createChangeMetadata(changeId, {
1070
+ cwd: options.cwd,
1044
1071
  title: options.title || `Mainline release: ${sourceIds.join(', ')}`,
1045
1072
  affected,
1046
1073
  changeMode: 'mainline-bundle',
1047
1074
  riskLevel: 'medium',
1075
+ sourceBase: bundleSourceBase,
1048
1076
  }),
1049
1077
  status: 'approved',
1050
1078
  approvedAt: createdAt,
1051
1079
  approvedBy: 'mainline-release-coordinator',
1052
1080
  approvalSummary: `Aggregate approved changes on authoritative mainline: ${sourceIds.join(', ')}`,
1053
1081
  bundledChanges: sourceIds,
1082
+ integratedChanges,
1054
1083
  createdAt,
1055
1084
  updatedAt: createdAt,
1056
1085
  };
@@ -1083,6 +1112,9 @@ function createMainlineSddBundle(options = {}) {
1083
1112
  changeId,
1084
1113
  mainlinePolicy: 'publish-from-main-v1',
1085
1114
  bundledChanges: sourceIds,
1115
+ sourceBase: bundleSourceBase,
1116
+ baseRevision: bundleSourceBase.baseCommit,
1117
+ integratedChanges,
1086
1118
  ...(options.integration ? { integration: options.integration } : {}),
1087
1119
  targets: normalizedTargets.activationTargets,
1088
1120
  logicalTargets: normalizedTargets.logicalTargets,
@@ -1092,6 +1124,8 @@ function createMainlineSddBundle(options = {}) {
1092
1124
  `openxiangda release publish --change ${changeId} --profile ${profile}`,
1093
1125
  planHash: releasePlanHash({
1094
1126
  changeId,
1127
+ baseRevision: bundleSourceBase.baseCommit,
1128
+ integratedChanges,
1095
1129
  runtimeMode,
1096
1130
  targets: normalizedTargets.activationTargets,
1097
1131
  commands,
@@ -1137,6 +1171,103 @@ function createMainlineSddBundle(options = {}) {
1137
1171
  };
1138
1172
  }
1139
1173
 
1174
+ function normalizeSddSourceBase(value) {
1175
+ if (!value?.baseCommit || !value?.treeHash) return null;
1176
+ return {
1177
+ repo: value.repo,
1178
+ baseCommit: String(value.baseCommit),
1179
+ treeHash: String(value.treeHash),
1180
+ };
1181
+ }
1182
+
1183
+ function runSddGit(cwd, args, options = {}) {
1184
+ const result = spawnSync('git', args, {
1185
+ cwd: cwd || process.cwd(),
1186
+ encoding: 'utf8',
1187
+ stdio: ['ignore', 'pipe', 'pipe'],
1188
+ });
1189
+ if (result.status !== 0 && !options.allowFailure) {
1190
+ const error = new Error(
1191
+ String(result.stderr || result.stdout || `git ${args.join(' ')} failed`).trim()
1192
+ );
1193
+ error.code = 'SDD_SOURCE_BASE_GIT_FAILED';
1194
+ throw error;
1195
+ }
1196
+ return {
1197
+ ok: result.status === 0,
1198
+ stdout: String(result.stdout || '').trim(),
1199
+ };
1200
+ }
1201
+
1202
+ function resolveMainlineBundleSourceBase(sourceChanges, options = {}) {
1203
+ const cwd = options.cwd || process.cwd();
1204
+ if (options.sourceBaseRef) {
1205
+ return readGitSourceBase(cwd, options.sourceBaseRef);
1206
+ }
1207
+ const missing = sourceChanges.filter(
1208
+ item => !normalizeSddSourceBase(item.meta.sourceBase)
1209
+ );
1210
+ if (missing.length > 0) {
1211
+ const error = new Error(
1212
+ `SDD_SOURCE_BASE_REQUIRED: ${missing
1213
+ .map(item => item.changeId)
1214
+ .join(', ')} 缺少创建变更时冻结的 sourceBase;请用 --source-base-ref <commit> 明确指定共同基线`
1215
+ );
1216
+ error.code = 'SDD_SOURCE_BASE_REQUIRED';
1217
+ error.changes = missing.map(item => item.changeId);
1218
+ throw error;
1219
+ }
1220
+ const sourceBases = sourceChanges.map(item =>
1221
+ normalizeSddSourceBase(item.meta.sourceBase)
1222
+ );
1223
+ const repos = Array.from(
1224
+ new Set(sourceBases.map(item => item.repo).filter(Boolean))
1225
+ );
1226
+ if (repos.length > 1) {
1227
+ const error = new Error(
1228
+ 'SDD_SOURCE_BASE_REPOSITORY_MISMATCH: mainline bundle 的来源变更不属于同一 Git 仓库'
1229
+ );
1230
+ error.code = 'SDD_SOURCE_BASE_REPOSITORY_MISMATCH';
1231
+ throw error;
1232
+ }
1233
+ for (const item of sourceChanges) {
1234
+ const task = options.integration?.requiredCommits?.find(
1235
+ candidate => candidate.changeId === item.changeId
1236
+ );
1237
+ if (
1238
+ task?.commit &&
1239
+ !runSddGit(cwd, [
1240
+ 'merge-base',
1241
+ '--is-ancestor',
1242
+ item.meta.sourceBase.baseCommit,
1243
+ task.commit,
1244
+ ], { allowFailure: true }).ok
1245
+ ) {
1246
+ const error = new Error(
1247
+ `SDD_SOURCE_BASE_NOT_ANCESTOR: ${item.changeId} 的 sourceBase 不是任务提交 ${String(task.commit).slice(0, 12)} 的祖先`
1248
+ );
1249
+ error.code = 'SDD_SOURCE_BASE_NOT_ANCESTOR';
1250
+ error.changeId = item.changeId;
1251
+ throw error;
1252
+ }
1253
+ }
1254
+ const commits = Array.from(
1255
+ new Set(sourceBases.map(item => item.baseCommit))
1256
+ );
1257
+ const commonCommit =
1258
+ commits.length === 1
1259
+ ? commits[0]
1260
+ : runSddGit(cwd, ['merge-base', '--octopus', ...commits]).stdout;
1261
+ if (!commonCommit) {
1262
+ const error = new Error(
1263
+ 'SDD_SOURCE_BASE_COMMON_ANCESTOR_REQUIRED: 来源变更没有可验证的共同 Git 基线'
1264
+ );
1265
+ error.code = 'SDD_SOURCE_BASE_COMMON_ANCESTOR_REQUIRED';
1266
+ throw error;
1267
+ }
1268
+ return readGitSourceBase(cwd, commonCommit);
1269
+ }
1270
+
1140
1271
  function loadSddChange(options = {}) {
1141
1272
  const governance = getSddGovernanceConfig(options);
1142
1273
  const changeId = normalizeChangeId(options.changeId);
@@ -245,6 +245,122 @@ function readStaticObjectProperty(expression, propertyNames, bindings) {
245
245
  return null;
246
246
  }
247
247
 
248
+ function resolveStaticExpression(expression, bindings, seen = new Set()) {
249
+ const value = unwrapExpression(expression);
250
+ if (!value || !ts.isIdentifier(value)) return value;
251
+ if (seen.has(value.text)) return value;
252
+ const initializer = bindings.get(value.text);
253
+ if (!initializer) return value;
254
+ const nextSeen = new Set(seen);
255
+ nextSeen.add(value.text);
256
+ return resolveStaticExpression(initializer, bindings, nextSeen);
257
+ }
258
+
259
+ function readStaticObjectPropertyExpression(
260
+ expression,
261
+ propertyNames,
262
+ bindings
263
+ ) {
264
+ const value = resolveStaticExpression(expression, bindings);
265
+ if (!value || !ts.isObjectLiteralExpression(value)) return null;
266
+ for (const property of value.properties) {
267
+ if (!ts.isPropertyAssignment(property)) continue;
268
+ if (!propertyNames.includes(propertyNameText(property.name))) continue;
269
+ return property.initializer;
270
+ }
271
+ return null;
272
+ }
273
+
274
+ function collectStaticQueryFieldNodes(
275
+ expression,
276
+ bindings,
277
+ result,
278
+ fieldPropertyNames,
279
+ seen = new Set()
280
+ ) {
281
+ const value = resolveStaticExpression(expression, bindings, seen);
282
+ if (!value || seen.has(value)) return;
283
+ seen.add(value);
284
+ if (ts.isArrayLiteralExpression(value)) {
285
+ for (const element of value.elements) {
286
+ collectStaticQueryFieldNodes(
287
+ element,
288
+ bindings,
289
+ result,
290
+ fieldPropertyNames,
291
+ seen
292
+ );
293
+ }
294
+ return;
295
+ }
296
+ if (!ts.isObjectLiteralExpression(value)) return;
297
+ for (const property of value.properties) {
298
+ if (ts.isSpreadAssignment(property)) {
299
+ collectStaticQueryFieldNodes(
300
+ property.expression,
301
+ bindings,
302
+ result,
303
+ fieldPropertyNames,
304
+ seen
305
+ );
306
+ continue;
307
+ }
308
+ if (!ts.isPropertyAssignment(property)) continue;
309
+ const name = propertyNameText(property.name);
310
+ if (fieldPropertyNames.has(name)) {
311
+ const field = readStaticString(property.initializer, bindings);
312
+ if (field) result.push({ field, node: property.initializer });
313
+ }
314
+ collectStaticQueryFieldNodes(
315
+ property.initializer,
316
+ bindings,
317
+ result,
318
+ fieldPropertyNames,
319
+ seen
320
+ );
321
+ }
322
+ }
323
+
324
+ function addFormFieldReferences(
325
+ formFieldReferences,
326
+ sourceFile,
327
+ workspaceRoot,
328
+ bindings,
329
+ formCode,
330
+ inputExpression,
331
+ api
332
+ ) {
333
+ if (!formCode || !inputExpression) return;
334
+ const fields = [];
335
+ for (const propertyName of ['filters', 'order']) {
336
+ const expression = readStaticObjectPropertyExpression(
337
+ inputExpression,
338
+ [propertyName],
339
+ bindings
340
+ );
341
+ if (expression) {
342
+ collectStaticQueryFieldNodes(
343
+ expression,
344
+ bindings,
345
+ fields,
346
+ new Set(
347
+ propertyName === 'order'
348
+ ? ['id', 'key', 'field', 'fieldId']
349
+ : ['key', 'field', 'fieldId']
350
+ )
351
+ );
352
+ }
353
+ }
354
+ for (const item of fields) {
355
+ formFieldReferences.push({
356
+ formCode,
357
+ field: item.field,
358
+ api,
359
+ ...sourceLocation(sourceFile, item.node, workspaceRoot),
360
+ });
361
+ }
362
+ }
363
+
248
364
  function sourceLocation(sourceFile, node, workspaceRoot) {
249
365
  const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
250
366
  return {
@@ -274,10 +390,46 @@ function addDynamicReferenceWarning(warnings, sourceFile, node, workspaceRoot, a
274
390
  });
275
391
  }
276
392
 
277
- function inspectResourceCall(node, sourceFile, workspaceRoot, bindings, references, warnings) {
393
+ function inspectResourceCall(
394
+ node,
395
+ sourceFile,
396
+ workspaceRoot,
397
+ bindings,
398
+ references,
399
+ formFieldReferences,
400
+ warnings
401
+ ) {
278
402
  if (!ts.isCallExpression(node)) return;
279
403
  const callee = getPropertyPath(node.expression, bindings);
280
- if (!callee || callee[0] !== 'ctx' || callee.length < 3) return;
404
+ if (!callee) return;
405
+ if (
406
+ callee.length === 1 &&
407
+ callee[0] === 'queryAdvancedFormPage'
408
+ ) {
409
+ const formCode = readStaticString(node.arguments[1], bindings);
410
+ if (formCode) {
411
+ addReference(
412
+ references,
413
+ sourceFile,
414
+ node,
415
+ workspaceRoot,
416
+ 'forms',
417
+ formCode,
418
+ 'queryAdvancedFormPage'
419
+ );
420
+ addFormFieldReferences(
421
+ formFieldReferences,
422
+ sourceFile,
423
+ workspaceRoot,
424
+ bindings,
425
+ formCode,
426
+ node.arguments[2],
427
+ 'queryAdvancedFormPage'
428
+ );
429
+ }
430
+ return;
431
+ }
432
+ if (callee[0] !== 'ctx' || callee.length < 3) return;
281
433
  const namespace = callee[1];
282
434
  const method = callee[2];
283
435
  const api = callee.join('.');
@@ -304,7 +456,22 @@ function inspectResourceCall(node, sourceFile, workspaceRoot, bindings, referenc
304
456
  if (namespace === 'form') {
305
457
  const code = readStaticObjectProperty(firstArgument, ['formCode', 'code'], bindings) ||
306
458
  readStaticString(firstArgument, bindings);
307
- if (code) addReference(references, sourceFile, node, workspaceRoot, 'forms', code, api);
459
+ if (code) {
460
+ addReference(references, sourceFile, node, workspaceRoot, 'forms', code, api);
461
+ addFormFieldReferences(
462
+ formFieldReferences,
463
+ sourceFile,
464
+ workspaceRoot,
465
+ bindings,
466
+ code,
467
+ ts.isObjectLiteralExpression(
468
+ resolveStaticExpression(firstArgument, bindings)
469
+ )
470
+ ? firstArgument
471
+ : node.arguments[1],
472
+ api
473
+ );
474
+ }
308
475
  else if (firstArgument) addDynamicReferenceWarning(warnings, sourceFile, node, workspaceRoot, api, 'forms');
309
476
  return;
310
477
  }
@@ -396,6 +563,7 @@ function analyzeTypeScriptEntry(options = {}) {
396
563
  const visited = new Set();
397
564
  const sourceDependencies = new Set();
398
565
  const references = [];
566
+ const formFieldReferences = [];
399
567
  const warnings = [];
400
568
 
401
569
  while (queue.length > 0) {
@@ -439,7 +607,15 @@ function analyzeTypeScriptEntry(options = {}) {
439
607
 
440
608
  const bindings = collectConstInitializers(sourceFile);
441
609
  const inspect = node => {
442
- inspectResourceCall(node, sourceFile, workspaceRoot, bindings, references, warnings);
610
+ inspectResourceCall(
611
+ node,
612
+ sourceFile,
613
+ workspaceRoot,
614
+ bindings,
615
+ references,
616
+ formFieldReferences,
617
+ warnings
618
+ );
443
619
  ts.forEachChild(node, inspect);
444
620
  };
445
621
  inspect(sourceFile);
@@ -486,6 +662,10 @@ function analyzeTypeScriptEntry(options = {}) {
486
662
  sourceDependencies: Array.from(sourceDependencies).sort(),
487
663
  resourceReferences,
488
664
  resourceReferenceDetails,
665
+ formFieldReferences: dedupeDetails(
666
+ formFieldReferences,
667
+ ['formCode', 'field', 'file', 'line', 'column', 'api']
668
+ ),
489
669
  warnings: dedupeDetails(warnings, ['code', 'file', 'line', 'column', 'api', 'message']),
490
670
  };
491
671
  }
@@ -610,11 +790,16 @@ function mergeAnalyses(workspaceRoot, entries) {
610
790
  ).sort(),
611
791
  ])
612
792
  );
793
+ const formFieldReferences = dedupeDetails(
794
+ analyses.flatMap(analysis => analysis.formFieldReferences || []),
795
+ ['formCode', 'field', 'file', 'line', 'column', 'api']
796
+ );
613
797
  return {
614
798
  entryFiles: entries.map(entry => workspaceRelativePath(workspaceRoot, entry)).sort(),
615
799
  sourceDependencies,
616
800
  resourceReferences,
617
801
  resourceReferenceDetails,
802
+ formFieldReferences,
618
803
  warnings: dedupeDetails(
619
804
  analyses.flatMap(analysis => analysis.warnings),
620
805
  ['code', 'file', 'line', 'column', 'api', 'message']
@@ -712,6 +897,7 @@ function validateManifestSourceBindings(options = {}) {
712
897
  entryFiles: target.entryFiles,
713
898
  sourceDependencies: target.sourceDependencies,
714
899
  resourceReferences: target.resourceReferences,
900
+ formFieldReferences: target.formFieldReferences,
715
901
  })),
716
902
  };
717
903
  }
@@ -59,6 +59,8 @@ openxiangda resource publish function --only function_a,function_b --profile <na
59
59
 
60
60
  Selectors apply before manifest parsing, source dependency analysis, and JS_CODE typecheck/build. A scoped Function/Automation plan must touch only the selected targets plus their transitive/shared/ambient dependencies; an unscoped plan intentionally retains full-workspace behavior. Resource commands use the canonical scoped builder packaged with the installed CLI for standard workspaces, so old checked-in builders still receive the optimization; refresh/bootstrap the workspace script only for equivalent manual `pnpm build-js-code` behavior. Nonstandard custom builders remain an explicit compatibility fallback.
61
61
 
62
+ Function/Automation source analysis also extracts statically declared Form filter and order fields. `resource plan` compares them with each bound Form's frozen online schema and reports `formFieldContracts`; publish fails before lease/write when a binding or field is missing. Fix the source or stage the Form schema in the same reviewed release instead of waiting for a production SQL-column error. Dynamic field names remain runtime-validated by the platform and return `FORM_FIELD_NOT_FOUND` as a configuration error.
63
+
62
64
  Source-triggered Function/Automation targets use Backend Release v2 when the platform exposes that capability. One child may mix create, source-only update, and manifest replacement update through explicit per-resource `operation/mode`; the CLI freezes the current Backend Release parent plus Git/change baseline, then runs `prepare -> verify -> activate` or stops verified for `--stage-only`. Activation CAS-checks the entire set and applies all updates in one transaction. `--stage-only` and Secret-bound Function publishing fail closed when Backend Release v2 is unavailable; compatibility fallback is limited to non-staged, non-Secret publishing after an explicit Backend head 404. Existing online bindings, contracts, metadata, trigger/view configuration, and enabled/published state remain unchanged; noops do not advance versions/timestamps. A deliberate whole-definition replacement requires exact `--only/--code` and `--replace-manifest --reason "<why>"`; SDD bypass does not imply replacement authority.
63
65
 
64
66
  Functions with a top-level `secretRefs` field use `backend_release_v2`, including an explicit empty list that removes bindings. This path never falls back to source PATCH. It requires the per-app Secret capability probe to grant `app_function_secrets_v1`, `trusted_node_v2`, `backend_release_v2`, and `atomic_staged_children_v2`; otherwise plan/publish fails closed.
@@ -84,7 +84,7 @@ For a single form edit, still keep the child staged until Root App finalize:
84
84
  openxiangda resource publish form-setting --only <formCode> --change <change> --profile <name>
85
85
  ```
86
86
 
87
- The Form resource bundle carries schema, settings, indexes, data-management, runtime-write, and public-access configuration under one CAS parent. It returns a canonical staged FormRelease and the CLI records it in the change-scoped staged-resources file. `workspace publish --form` is limited to an intentional first-time bootstrap or isolated repair outside a governed multi-resource release; it is not the normal release path.
87
+ The Form resource bundle carries schema, settings, indexes, data-management, runtime-write, public-access configuration, and changed form permission groups under one CAS parent. It returns a canonical staged FormRelease and the CLI records it in the change-scoped staged-resources file. `workspace publish --form` is limited to an intentional first-time bootstrap or isolated repair outside a governed multi-resource release; it is not the normal release path.
88
88
 
89
89
  For Phase 6 React SPA workspaces, `app-workspace.config.ts` should declare
90
90
  `runtimeMode: "react-spa"`. In that mode, `workspace publish --form <code>` is
@@ -110,7 +110,7 @@ Do not use `openxiangda form create` as the normal way to generate a user-facing
110
110
  ## Form Release / CAS Safety
111
111
 
112
112
  - Treat all Form configuration as one immutable release stream. Freeze `revision`, `etag`, and `activeFormReleaseHead` from one snapshot before writing.
113
- - Publish declared schema, packages, settings, indexes, data-management, runtime-write, and public-access configuration through one `resource publish form-setting --only <codes> --change <change>` Form `bundle` mutation. Resource Form bundles are stage-only by default and return immutable `contentHash` plus a canonical `stagedResource` (stable identity is `formUuid`); the CLI merges each entry into `.openxiangda/releases/<change>/staged-resources.json`. Root `app-finalize` atomically switches all child heads plus the App head. Use direct `--activate` only for an intentional form-only repair outside a governed multi-resource release; never use `workspace publish --form` inside an atomic React SPA release.
113
+ - Publish declared schema, packages, settings, indexes, data-management, runtime-write, public-access configuration, and `formPermissionGroups` through one Form `bundle` mutation. `resource publish form-setting,form-permission-group --only <codes> --change <change>` groups changes by `formUuid`, stages one immutable FormRelease per form, and never falls back to direct live permission-group writes. Resource Form bundles return immutable `contentHash` plus a canonical `stagedResource` (stable identity is `formUuid`); the CLI merges each entry into `.openxiangda/releases/<change>/staged-resources.json`. Root `app-finalize` atomically switches all child heads plus the App head. Use direct `--activate` only for an intentional form-only repair outside a governed multi-resource release; never use `workspace publish --form` inside an atomic React SPA release.
114
114
  - Every mutation must carry `If-Match`, `expectedRevision`, `expectedParent`, and a stable artifact ID. Creation starts at revision `0`.
115
115
  - A revision/head conflict is not retryable. Stop with zero later writes, re-plan, and explicitly merge the competing change; never silently adopt the newer live head.
116
116
  - Use `openxiangda form release-head|release-list|release-detail|release-diff` for inspection and `release-activate|release-rollback|release-abort` for controlled recovery. See `docs/openxiangda-form-releases.md` in the OpenXiangda tool repository.
@@ -159,6 +159,14 @@ openxiangda permission form-group-create sales_limited \
159
159
  for real backend read/write field access (`edit`, `readonly`, `hidden`) with
160
160
  `defaultAccess` plus field-ID exceptions.
161
161
 
162
+ For governed application publishing, declare groups under
163
+ `src/resources/formPermissionGroups` and publish them through exact
164
+ `resource publish ... --only ... --change ...` scope. The CLI folds changed
165
+ groups into the owning Form's immutable FormRelease, freezes a permission-group
166
+ parent hash, and keeps live rows unchanged until Root App finalize. The
167
+ low-level `permission form-group-create/update/delete` commands are for
168
+ intentional maintenance and diagnostics, not an atomic multi-resource release.
169
+
162
170
  ## Inspection
163
171
 
164
172
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.177",
3
+ "version": "1.0.178",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -100,6 +100,7 @@
100
100
  "test:publish-lease-heartbeat": "node scripts/publish-lease-heartbeat-smoke.mjs",
101
101
  "test:release-telemetry": "node scripts/release-telemetry-smoke.mjs",
102
102
  "test:release-explain": "node scripts/release-explain-smoke.mjs",
103
+ "test:release-error-classification": "node scripts/release-error-classification-smoke.mjs",
103
104
  "test:task-status": "node scripts/task-status-smoke.mjs",
104
105
  "test:policy": "node scripts/policy-smoke.mjs",
105
106
  "test:release-plan": "node scripts/release-plan-smoke.mjs",
@@ -111,6 +112,7 @@
111
112
  "test:app-release-cli": "node scripts/app-release-cli-smoke.mjs",
112
113
  "test:form-release-cas": "node scripts/form-release-cas-smoke.mjs",
113
114
  "test:source-dependencies": "node scripts/source-dependencies-smoke.mjs",
115
+ "test:form-field-contract": "node scripts/form-field-contract-smoke.mjs",
114
116
  "test:package-runtime-dependencies": "node scripts/package-runtime-dependencies-smoke.mjs",
115
117
  "test:packed-cli": "node scripts/packed-cli-smoke.mjs",
116
118
  "test:runtime-deploy": "node scripts/runtime-deploy-smoke.mjs",