@aefree/pi-plastic 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/docs/2026-10-06-merging.md +7 -6
- package/package.json +4 -4
- package/src/domain/branch-create-contract.ts +2 -1
- package/src/domain/checkin-contract.ts +8 -2
- package/src/domain/merge-output.ts +27 -4
- package/src/domain/merged-workspace.ts +85 -0
- package/src/domain/server-merge-contract.ts +1 -1
- package/src/domain/status-xml.ts +3 -2
- package/src/domain/switch-contract.ts +2 -1
- package/src/domain/workspace-merge-contract.ts +6 -3
- package/src/operations/closeout-receipt.ts +9 -4
- package/src/operations/server-merge.ts +5 -4
- package/src/operations/workspace-merge-receipt.ts +9 -6
- package/src/pi/checkin-output.ts +1 -1
- package/src/pi/closeout-output.ts +1 -1
- package/src/pi/server-merge-output.ts +2 -1
- package/src/pi/status-output.ts +2 -2
- package/src/pi/workspace-merge-output.ts +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project follows semantic versioning for public package releases.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## [0.8.4] - 2026-10-20
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Validate against exact Pi 1.3.0 development dependencies; retain manifest-derived installed-version checks.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Admit observed behind-head branch headers for switching and relative branch creation without treating branch head as loaded content.
|
|
19
|
+
- Support observed binary and CO/CP/RP XML status records while retaining whole-document completeness, original status codes and unavailable base-revision identity.
|
|
20
|
+
- Correlate observed automatic resolved replacement/text/mixed merges across applying records, machine pending and source-linked full status before checkin readiness. Revalidate independent closeout observations, keep unresolved text merges stopped before checkin, and never replay uncertain mutations.
|
|
21
|
+
- Admit native RP checkin item events from the captured successful root profile; unknown framing, multiple changesets and unproven Xlink identity remain unsupported.
|
|
22
|
+
|
|
23
|
+
## [0.8.3] - 2026-10-13
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Accept strictly validated server analysis `APPLY ADD/FSPROT` and `DIS_OP_WARN CHG` records, expose discard-warning counts without inferring content equality, and distinguish malformed evidence from unsupported operations. Applying-merge verification remains unchanged.
|
|
28
|
+
|
|
10
29
|
## [0.8.2] - 2026-10-13
|
|
11
30
|
|
|
12
31
|
- Add focused real-Pi codemode declaration coverage for existing Plastic input constraints, including explicit absence of schema patterns/defaults.
|
|
@@ -28,19 +28,20 @@ The closed producer receipts distinguish applying-command completion from overal
|
|
|
28
28
|
|
|
29
29
|
Strict bounded inputs reject source-as-option/stdin, unsafe Unicode and unknown controls. Source/workdir cap 4,096 code units; aliases keep canonical precedence.
|
|
30
30
|
|
|
31
|
-
At most one applying merge
|
|
31
|
+
At most one applying merge, followed by short/full or machine/full reads. Conflict records alone block; declaration/action pairs remain candidates until resolved pending/link/path observations agree. Exit 0 with unsupported reads retains commandCompleted=true but ok=false/uncertain and unknown readiness.
|
|
32
32
|
|
|
33
33
|
No swallowed read failure, raw diagnostic facts, retry, strategy switch, export/backup/version query or rollback. Possible-start failure never proves absent effects or safe replay. Unobserved source/workspace identity, pending items, finalized metadata and preservation stay null.
|
|
34
34
|
|
|
35
35
|
### Admitted readiness profiles
|
|
36
36
|
|
|
37
|
-
Windows CM 11.0.16.10371
|
|
37
|
+
Windows CM admission is narrow: the 11.0.16.10371 contract label is retained; fixtures additionally attest 11.0.16.10468, not client-family support:
|
|
38
38
|
|
|
39
39
|
- Exact nonce-framed STATUS/ALREADY_CONNECTED, FILE_CONFLICT, APPLY/ADD and paired DO_COPIED records.
|
|
40
40
|
- Empty profile: successful complete empty short status plus exact header-only standard status and no stderr.
|
|
41
41
|
- Copied-add profile: paired applying paths, CP-only pending records and machine/full-status correlation; up to 128 files under unchanged byte/frame limits and one merge changeset.
|
|
42
|
+
- Resolved merged profile: FILE_SRC/DO_COPIED replacement, FILE_CONFLICT/DO_MERGE text merge, or observed mixed copies/replacements/text. Automatic Windows merges require unique scoped apply/pending paths, resolved merge metadata and matching full-status source link/namespace/rows. Identical opcodes and exit zero occurred in unresolved experiments too: CO/NO_MERGES cannot prove readiness.
|
|
42
43
|
|
|
43
|
-
Unknown/
|
|
44
|
+
Unknown profiles/cardinalities, unrelated pending rows or extra links remain unknown—not zero conflicts or rollback. Forced/cherrypicked policies, merged moves/deletes/directories, binary conflicts, Xlinks and localization remain unsupported. Core rejection and independent closeout post-read gates prevent checkin on unknown readiness.
|
|
44
45
|
|
|
45
46
|
`supported-no-unresolved-signals` is point-in-time admitted evidence, not transaction finality, target-head/source-link/exclusivity/other-workspace/Xlink proof. The agent may inspect and choose a supported resolution; the tool doesn't force one.
|
|
46
47
|
|
|
@@ -70,11 +71,11 @@ Only one matching full-source/direct-parent row is admitted. Two rows mean ambig
|
|
|
70
71
|
|
|
71
72
|
`unsupported_readiness` is not an applying-command failure or rollback receipt. Inspect `commandCompleted`, capture completeness, `protocol` and each stage admission. Diagnostics distinguish unsupported applying output, applying warnings, pending/path correlation and full-status correlation. Stop checkin and do not automatically reapply the merge. Use separately authorized read-only inspection to establish current pending/conflict state; retain sanitized producer evidence for unsupported-profile analysis and verify the package revision actually loaded by the process (a source checkout alone is not proof).
|
|
72
73
|
|
|
73
|
-
Copied-
|
|
74
|
+
Copied-only admission excludes replacements/deletions; correlated profiles add sourced replacement/text/copy combinations. Synthetic tests prove neither historical recovery nor loaded revision.
|
|
74
75
|
|
|
75
76
|
### Readiness and message admission
|
|
76
77
|
|
|
77
|
-
Independent readiness requires complete
|
|
78
|
+
Independent readiness requires complete capture, empty stderr and an admitted clean, copied or resolved-merged profile. Merged closeout re-reads machine pending and correlates resolved rows/source link with the subsequent full loaded-target observation. Copied readiness correlates machine/full status with source, namespace, metadata and paths. Volatile display is opaque. Unknown/directory/duplicate/out-of-scope rows stop; CP retains its code but maps locally to added for checkin. Final pending reads never turn failure into zero.
|
|
78
79
|
|
|
79
80
|
Existing message/card trimming and explicit-message precedence remain. Derived messages must satisfy the checkin owner's text/4, 096-unit bounds before target mutation. A card-only default inserts newlines rejected by that owner; supply an admitted explicit message instead. This is a current restriction, not permission to widen checkin text admission or silently drop the card.
|
|
80
81
|
|
|
@@ -117,7 +118,7 @@ No source-link/head-exclusive/global/Xlink/rollback proof, retries, head unifica
|
|
|
117
118
|
|
|
118
119
|
### Agent conflict-resolution workflow
|
|
119
120
|
|
|
120
|
-
1. **Inspect:** call `mode="analyze"` to execute `cm merge <source> --to=<target>` without `--merge`. Unlike `preflight`, this contacts the server. Read `conflictPaths`, parse/capture completeness and omission counts. Empty, malformed,
|
|
121
|
+
1. **Inspect:** call `mode="analyze"` to execute `cm merge <source> --to=<target>` without `--merge`. Unlike `preflight`, this contacts the server. Read `conflictPaths`, parse/capture completeness and omission counts. Analysis supports the captured three-field `APPLY ADD/FSPROT` and `DIS_OP_WARN CHG` profiles with bounded repository-absolute paths. `parse.warningsObserved` counts supported CHG discard warnings; it does not prove identical contributor content. Empty, malformed, unsupported-operation/subtype (including directory-conflict), other warning or failed output remains uncertain, not a clean merge. These analysis profiles do not broaden applying-success verification. `analyzed` means a supported observation, not absence of conflicts.
|
|
121
122
|
2. **Understand contributors:** independently inspect base/source/destination for each conflict. The exact destination conflict path is usable as a resolution key, not a workspace path. This tool does not retrieve content or assign meanings to the numeric FILE_CONFLICT fields. If needed, use a read-only `cm merge <qualified-source> --to=<qualified-target> --printcontributors` inspection, without `--merge`, to establish contributor identities. Use `plastic_diff(mode="revisions", ...)` for deliberate comparisons after verifying file/repository/server-qualified selectors. For full bytes unavailable through typed tools, follow the [historical export fallback](../skills/using-plastic/references/changeset-operations.md#historical-export-fallback): fresh external temporary destinations only, never canonical workspace files or guessed revision IDs. Writable xlinks need independently verified linked-repository identities; do not qualify their revision IDs against the root repository by assumption.
|
|
122
123
|
3. **Resolve and validate:** write merged content into new local result files outside the workspace; preserve immutable contributor baselines. Review the result and run relevant validation before submission. Binary files may use `keep`, but choosing a side deliberately discards the other contributor's conflicting changes. Directory conflicts are not solved by this JSON feature; stop and choose a separately authorized workflow.
|
|
123
124
|
4. **Submit:** create a UTF-8 JSON file as below and pass its absolute path as `fileConflictsResolutionsFile` with a merge message. Each `path` must exactly match its observed FILE_CONFLICT destination path, including any writable-xlink prefix. Each entry requires exactly one of `keep` (`source`/`destination`) or an absolute readable regular `resultFile`. Duplicate paths and unknown fields are rejected. Input validation caps JSON at 1 MiB / 500 entries and records its SHA-256 plus counts, not file contents. Applying rechecks the JSON hash and result-file readability after help, before dispatch.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aefree/pi-plastic",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.19.0"
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"typecheck": "tsc -p tsconfig.json",
|
|
38
38
|
"pretest": "npm run typecheck",
|
|
39
39
|
"test:docs": "tsx tests/plastic-documentation.test.ts",
|
|
40
|
-
"test": "tsx tests/pi-version-selection.test.ts && tsx tests/plastic-documentation.test.ts && tsx tests/eval-mutation-guard.test.ts && tsx tests/tool-definition.test.ts && tsx tests/release-registry-preflight.test.ts && tsx tests/plastic-tool-validation.ts && tsx tests/plastic-status-machine-readable.test.ts && tsx tests/plastic-status-output.test.ts && tsx tests/plastic-status-xml.test.ts && tsx tests/plastic-branch-output.test.ts && tsx tests/plastic-checkin-output.test.ts && tsx tests/plastic-branch-create-output.test.ts && tsx tests/plastic-switch-output.test.ts && tsx tests/plastic-update-output.test.ts && tsx tests/plastic-add-output.test.ts && tsx tests/plastic-undo-output.test.ts && tsx tests/plastic-removal-output.test.ts && tsx tests/plastic-workspace-merge-output.test.ts && tsx tests/plastic-workspace-merge-closeout.test.ts && tsx tests/plastic-closeout-output.test.ts && tsx tests/plastic-closeout-parent.test.ts && tsx tests/plastic-copied-closeout.test.ts && tsx tests/plastic-copied-cardinality.test.ts && tsx tests/plastic-branch-delete-output.test.ts && tsx tests/plastic-object-delete-output.test.ts && tsx tests/plastic-object-write-output.test.ts && tsx tests/plastic-receipt-integration.test.ts && tsx tests/plastic-checkin-core-closeout.test.ts && tsx tests/plastic-selected-collector-retirement.test.ts && tsx tests/plastic-checkin-path-resolution.ts && tsx tests/plastic-diff-workflow.test.ts && tsx tests/plastic-diff-revisions-output.test.ts && tsx tests/plastic-local-snapshot.test.ts && tsx tests/plastic-workspace-diff.test.ts && tsx tests/plastic-process-lifecycle.test.ts && tsx tests/plastic-module-boundaries.test.ts && tsx tests/plastic-patch-command.test.ts && tsx tests/plastic-patch-output.test.ts && tsx tests/plastic-capability-diagnostics.test.ts && tsx tests/plastic-extension-registration.test.ts && tsx tests/plastic-dynamic-tool-loading.test.ts && tsx tests/plastic-tool-rendering.test.ts && tsx tests/plastic-rendering-runtime-isolation.test.ts && tsx tests/plastic-branch-query.test.ts && tsx tests/plastic-workspace.test.ts && tsx tests/plastic-workspace-list-output.test.ts && tsx tests/plastic-shelveset-list-output.test.ts && tsx tests/plastic-code-review-find-output.test.ts && tsx tests/plastic-mutation-execution.test.ts && tsx tests/plastic-merge-branches.test.ts && tsx tests/plastic-server-merge-output.test.ts && tsx tests/plastic-server-merge-resolutions.test.ts && tsx tests/file-discovery-filter.test.ts && tsx tests/file-discovery-filter-registration.test.ts && tsx tests/packed-copy-owner-conflict.test.ts && tsx tests/plastic-branch-status.test.ts && tsx tests/plastic-constrained-sampling.test.ts && tsx tests/bash-cm-diff-guard-validation.ts",
|
|
40
|
+
"test": "tsx tests/pi-version-selection.test.ts && tsx tests/plastic-documentation.test.ts && tsx tests/eval-mutation-guard.test.ts && tsx tests/tool-definition.test.ts && tsx tests/release-registry-preflight.test.ts && tsx tests/plastic-tool-validation.ts && tsx tests/plastic-status-machine-readable.test.ts && tsx tests/plastic-status-output.test.ts && tsx tests/plastic-status-xml.test.ts && tsx tests/plastic-branch-output.test.ts && tsx tests/plastic-checkin-output.test.ts && tsx tests/plastic-branch-create-output.test.ts && tsx tests/plastic-switch-output.test.ts && tsx tests/plastic-update-output.test.ts && tsx tests/plastic-add-output.test.ts && tsx tests/plastic-undo-output.test.ts && tsx tests/plastic-removal-output.test.ts && tsx tests/plastic-workspace-merge-output.test.ts && tsx tests/plastic-workspace-merge-closeout.test.ts && tsx tests/plastic-merged-protocol.test.ts && tsx tests/plastic-merged-closeout.test.ts && tsx tests/plastic-closeout-output.test.ts && tsx tests/plastic-closeout-parent.test.ts && tsx tests/plastic-copied-closeout.test.ts && tsx tests/plastic-copied-cardinality.test.ts && tsx tests/plastic-branch-delete-output.test.ts && tsx tests/plastic-object-delete-output.test.ts && tsx tests/plastic-object-write-output.test.ts && tsx tests/plastic-receipt-integration.test.ts && tsx tests/plastic-checkin-core-closeout.test.ts && tsx tests/plastic-selected-collector-retirement.test.ts && tsx tests/plastic-checkin-path-resolution.ts && tsx tests/plastic-diff-workflow.test.ts && tsx tests/plastic-diff-revisions-output.test.ts && tsx tests/plastic-local-snapshot.test.ts && tsx tests/plastic-workspace-diff.test.ts && tsx tests/plastic-process-lifecycle.test.ts && tsx tests/plastic-module-boundaries.test.ts && tsx tests/plastic-patch-command.test.ts && tsx tests/plastic-patch-output.test.ts && tsx tests/plastic-capability-diagnostics.test.ts && tsx tests/plastic-extension-registration.test.ts && tsx tests/plastic-dynamic-tool-loading.test.ts && tsx tests/plastic-tool-rendering.test.ts && tsx tests/plastic-rendering-runtime-isolation.test.ts && tsx tests/plastic-branch-query.test.ts && tsx tests/plastic-workspace.test.ts && tsx tests/plastic-workspace-list-output.test.ts && tsx tests/plastic-shelveset-list-output.test.ts && tsx tests/plastic-code-review-find-output.test.ts && tsx tests/plastic-mutation-execution.test.ts && tsx tests/plastic-merge-branches.test.ts && tsx tests/plastic-server-merge-output.test.ts && tsx tests/plastic-server-merge-resolutions.test.ts && tsx tests/file-discovery-filter.test.ts && tsx tests/file-discovery-filter-registration.test.ts && tsx tests/packed-copy-owner-conflict.test.ts && tsx tests/plastic-branch-status.test.ts && tsx tests/plastic-constrained-sampling.test.ts && tsx tests/bash-cm-diff-guard-validation.ts",
|
|
41
41
|
"preview:tools": "tsx tests/plastic-tool-rendering.test.ts --preview",
|
|
42
42
|
"test:host:status": "tsx tests/pi-host-status-output.test.ts",
|
|
43
43
|
"test:host:declarations": "tsx tests/pi-host-declarations.test.ts",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@aefree/pi-file-discovery": "^0.1.1",
|
|
50
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
51
|
-
"@earendil-works/pi-tui": "1.
|
|
50
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
51
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
52
52
|
"@types/node": "22.20.4",
|
|
53
53
|
"tsx": "4.23.15",
|
|
54
54
|
"typebox": "^1.3.8",
|
|
@@ -32,7 +32,8 @@ export const safeBranchCreateText = (s: string): boolean => s.length <= 4096 &&
|
|
|
32
32
|
export function parseBranchCreateParent(output: string): string | null {
|
|
33
33
|
const lines = output.split(/\r?\n/).filter(line => line.length > 0);
|
|
34
34
|
if (lines.length !== 1) return null;
|
|
35
|
-
|
|
35
|
+
// Resolve the loaded branch only; a numeric head does not change its identity.
|
|
36
|
+
const match = /^(\/[^@]+)@[^@]+@[^\s]+ \(cs:(?:0|[1-9][0-9]{0,19}) - head(?::(?:0|[1-9][0-9]{0,19}))?\)$/u.exec(lines[0]!);
|
|
36
37
|
if (!match || !safeBranchCreateText(match[1]!) || !/^\/(?:[^/@\\]+)(?:\/[^/@\\]+)*$/u.test(match[1]!) || match[1] !== match[1]!.trim() || /[?\uFFFD]/u.test(match[1]!)) return null;
|
|
37
38
|
return match[1]!;
|
|
38
39
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseMergedPending } from "./merged-workspace";
|
|
1
2
|
import { parseMachineReadablePendingItems, type PendingItem } from "./pending";
|
|
2
3
|
import { parseCopiedPending } from "./copied-merge";
|
|
3
4
|
export type CheckinRequest = { message: string; paths?: string[]; applyChanged?: boolean; includePrivate?: boolean; includeAll?: boolean; updateAfter?: boolean; preflight?: boolean; format?: "text" | "json"; workdir?: string };
|
|
@@ -6,7 +7,7 @@ export type { CheckinAttempt, CheckinCapture } from "../execution/checkin-comman
|
|
|
6
7
|
export type CheckinChangeset = { id: string; branch: string; repository: string; server: string; mount: "/" };
|
|
7
8
|
export type CheckinSummary = { totalPending: number; tracked: number; private: number; added: number; changed: number; moved: number; deleted: number; other: number };
|
|
8
9
|
export type CheckinStep = { name: "pending-before" | "initial-checkin" | "private-add" | "private-retry" | "pending-recovery" | "fallback-checkin" | "pending-after" | "merge-diagnostic"; operation: "status" | "checkin" | "add"; attempt: CheckinAttempt; capture: CheckinCapture; effect: "not-attempted" | "uncertain" | "command-completed" | "changeset-created" | "read-observed"; evidence: "unadmitted" | "admitted"; records: number; summary: CheckinSummary | null; changeset: CheckinChangeset | null };
|
|
9
|
-
export type CheckinItemEvent = { operation: "CO" | "AD" | "DE" | "CP"; path: string } | { operation: "MV"; sourcePath: string; path: string };
|
|
10
|
+
export type CheckinItemEvent = { operation: "CO" | "AD" | "DE" | "CP" | "RP"; path: string } | { operation: "MV"; sourcePath: string; path: string };
|
|
10
11
|
export type CheckinData = { sourceAdmission: "windows-cm11.0.16.10371-observed"; requestedPaths: string[]; includedPaths: string[]; fallbackPaths: string[]; excludedPaths: Array<{ path: string; reason: string }>; privateAddPaths: string[]; blockedPrivatePaths: Array<{ path: string; reason: string }>; itemEvents: CheckinItemEvent[]; observedChangesets: CheckinChangeset[]; command: string[] | null; steps: CheckinStep[]; autoEnabledApplyChanged: boolean; usedFallbackRetry: boolean; usedPrivateAutoAddRecovery: boolean; pendingBefore: CheckinSummary | null; pendingAfter: CheckinSummary | null; wouldRun: boolean; omittedReferences: number; scopeExhaustion: "unverified"; branchHead: "unverified"; serverAliasEquivalence: "unverified"; xlinkEffects: "unverified"; mergeLinkIdentity: "unverified" };
|
|
11
12
|
type Base = { schemaVersion: 1; action: "checkin"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: boolean } };
|
|
12
13
|
export type CheckinReceipt = Base & (
|
|
@@ -37,7 +38,7 @@ export function parseCheckinEvidence(text: string, s: CheckinSeparators) {
|
|
|
37
38
|
const fields = line.slice(s.start.length, -s.end.length).split(s.field), [op, value] = fields;
|
|
38
39
|
if (op === "CI_START" && fields.length === 1) { started++; if (index !== 0) malformed = true; }
|
|
39
40
|
else if (op === "STAGE" && fields.length === 2 && value.length <= 4096 && !checkinUnsafeText.test(value)) { if (changesets.length) malformed = true; }
|
|
40
|
-
else if ((op === "CO" || op === "AD" || op === "DE" || op === "CP") && fields.length === 2 && absolute(value)) { events.push({ operation: op, path: value }); if (changesets.length) malformed = true; }
|
|
41
|
+
else if ((op === "CO" || op === "AD" || op === "DE" || op === "CP" || op === "RP") && fields.length === 2 && absolute(value)) { events.push({ operation: op, path: value }); if (changesets.length) malformed = true; }
|
|
41
42
|
else if (op === "MV" && fields.length === 3 && absolute(value) && absolute(fields[2])) { events.push({ operation: "MV", sourcePath: value, path: fields[2] }); if (changesets.length) malformed = true; }
|
|
42
43
|
else if (op === "CHANGESET" && fields.length === 2) { const cs = parseCheckinChangeset(value); if (cs) changesets.push(cs); else malformed = true; }
|
|
43
44
|
else malformed = true;
|
|
@@ -47,6 +48,11 @@ export function parseCheckinEvidence(text: string, s: CheckinSeparators) {
|
|
|
47
48
|
}
|
|
48
49
|
export function parseCheckinPending(text: string, cwd: string) {
|
|
49
50
|
// statusCode CP retained; added kind is local checkin selection, not legacy migration.
|
|
51
|
+
const merged = parseMergedPending(text, cwd);
|
|
52
|
+
if (merged) {
|
|
53
|
+
const items = parseMachineReadablePendingItems(text, cwd).map(item => ({ ...item, kind: item.statusCode === "CP" ? "added" as const : "changed" as const }));
|
|
54
|
+
return { admitted: items.length === merged.rows.length, records: merged.rows.length + 1, items, repository: merged.repository, server: merged.server };
|
|
55
|
+
}
|
|
50
56
|
const copied = parseCopiedPending(text, cwd);
|
|
51
57
|
if (copied) {
|
|
52
58
|
const items = parseMachineReadablePendingItems(text, cwd).map(item => ({ ...item, kind: "added" as const }));
|
|
@@ -209,9 +209,10 @@ export type ServerMergeParse = {
|
|
|
209
209
|
changesets: ServerChangeset[];
|
|
210
210
|
isAlreadyConnected: boolean;
|
|
211
211
|
hasConflict: boolean;
|
|
212
|
+
warningsObserved: number;
|
|
212
213
|
};
|
|
213
214
|
|
|
214
|
-
export const parseServerMergeOutput = (output: string, separators: { start: string; end: string; field: string }): ServerMergeParse =>
|
|
215
|
+
export const parseServerMergeOutput = (output: string, separators: { start: string; end: string; field: string }, mode: "apply" | "analyze" = "apply"): ServerMergeParse =>
|
|
215
216
|
{
|
|
216
217
|
const records: ServerMergeRecord[] = [];
|
|
217
218
|
const unknownOperations = new Set<string>();
|
|
@@ -258,10 +259,9 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
|
|
|
258
259
|
else
|
|
259
260
|
{
|
|
260
261
|
records.push({ operation, fields });
|
|
261
|
-
if (!["FILE_SRC", "CHANGESET", "STATUS", "FILE_CONFLICT"].includes(operation))
|
|
262
|
+
if (!["FILE_SRC", "CHANGESET", "STATUS", "FILE_CONFLICT", "APPLY", "DIS_OP_WARN"].includes(operation))
|
|
262
263
|
{
|
|
263
264
|
unknownOperations.add(operation);
|
|
264
|
-
malformed = true;
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
267
|
}
|
|
@@ -273,6 +273,7 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
|
|
|
273
273
|
const safe = (s: string) => s.length <= 4096 && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
|
|
274
274
|
let isAlreadyConnected = false;
|
|
275
275
|
let hasConflict = false;
|
|
276
|
+
let warningsObserved = 0;
|
|
276
277
|
for (const record of records)
|
|
277
278
|
{
|
|
278
279
|
switch (record.operation)
|
|
@@ -283,6 +284,28 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
|
|
|
283
284
|
malformed = true;
|
|
284
285
|
}
|
|
285
286
|
break;
|
|
287
|
+
case "APPLY":
|
|
288
|
+
case "DIS_OP_WARN":
|
|
289
|
+
{
|
|
290
|
+
// Only captured analysis profiles are supported. CHG is an observed
|
|
291
|
+
// discard warning, not proof of identical contributor content.
|
|
292
|
+
const subtype = record.fields[1]!;
|
|
293
|
+
const supported = record.operation === "APPLY" ? ["ADD", "FSPROT"].includes(subtype) : subtype === "CHG";
|
|
294
|
+
const validFields = record.fields.length === 3 && record.fields[2]!.startsWith("/") && safe(record.fields[2]!);
|
|
295
|
+
if (!validFields)
|
|
296
|
+
{
|
|
297
|
+
malformed = true;
|
|
298
|
+
}
|
|
299
|
+
if (!supported || mode !== "analyze")
|
|
300
|
+
{
|
|
301
|
+
unknownOperations.add(record.operation + "/" + subtype);
|
|
302
|
+
}
|
|
303
|
+
else if (record.operation === "DIS_OP_WARN" && validFields)
|
|
304
|
+
{
|
|
305
|
+
warningsObserved++;
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
286
309
|
case "CHANGESET":
|
|
287
310
|
{
|
|
288
311
|
if (record.fields.length !== 2)
|
|
@@ -323,5 +346,5 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
|
|
|
323
346
|
break;
|
|
324
347
|
}
|
|
325
348
|
}
|
|
326
|
-
return { records, malformed, unknownOperations: [...unknownOperations], changesets, isAlreadyConnected, hasConflict };
|
|
349
|
+
return { records, malformed, unknownOperations: [...unknownOperations], changesets, isAlreadyConnected, hasConflict, warningsObserved };
|
|
327
350
|
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { win32 } from "node:path";
|
|
2
|
+
import { copiedPathKey, type CopiedPending } from "./copied-merge";
|
|
3
|
+
import { parseSwitchLoadedBranch, parseSwitchTarget, safeSwitchValue } from "./switch-contract";
|
|
4
|
+
|
|
5
|
+
const decimal = /^(0|[1-9][0-9]{0,19})$/;
|
|
6
|
+
const positive = /^[1-9][0-9]{0,19}$/;
|
|
7
|
+
const safe = (s: string) => safeSwitchValue(s) && !/[?\uFFFD]/u.test(s);
|
|
8
|
+
const component = (part: string) => part !== "" && part !== "." && part !== ".." && part === part.trim() && !part.endsWith(".") && !/[\\/:*"<>|]/.test(part) && !/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(part);
|
|
9
|
+
const absolute = (s: string) => safe(s) && /^[A-Za-z]:\\/.test(s) && s.slice(3).split("\\").every(component);
|
|
10
|
+
const relative = (s: string) => safe(s) && s.startsWith("/") && s.slice(1).split("/").every(component);
|
|
11
|
+
export type MergedDeclaration = { path: string; actionPath: string; status: "CP" | "RP" | "CO" };
|
|
12
|
+
export type MergedPending = CopiedPending & { rows: Array<{ path: string; status: "CP" | "RP" | "CO" }> };
|
|
13
|
+
/** A declaration/action pair is only a candidate, never resolution proof. Numeric
|
|
14
|
+
* FILE fields are bounded producer tokens, not asserted revision identities. */
|
|
15
|
+
export function parseMergedApply(text: string, token: string): MergedDeclaration[] | null {
|
|
16
|
+
if (Buffer.byteLength(text, "utf8") > 65536) return null;
|
|
17
|
+
const start = token + "S__", end = token + "E__", field = token + "F__";
|
|
18
|
+
const declarations: Array<{ path: string; status: MergedDeclaration["status"] }> = [], actions: Array<{ path: string; status: "copy" | "merge" }> = [];
|
|
19
|
+
let rest = text, count = 0;
|
|
20
|
+
while (rest.trim() !== "") {
|
|
21
|
+
if (++count > 256 || !rest.startsWith(start)) return null;
|
|
22
|
+
const e = rest.indexOf(end, start.length); if (e < 0) return null;
|
|
23
|
+
const f = rest.slice(start.length, e).split(field); rest = rest.slice(e + end.length).replace(/^\r?\n/, "");
|
|
24
|
+
if (f.length === 2 && /^(DO_COPIED|DO_MERGE)$/.test(f[0]) && absolute(f[1])) actions.push({ path: f[1], status: f[0] === "DO_MERGE" ? "merge" : "copy" });
|
|
25
|
+
else if (!actions.length && f.length === 3 && f[0] === "APPLY" && f[1] === "ADD" && relative(f[2])) declarations.push({ path: f[2], status: "CP" });
|
|
26
|
+
else if (!actions.length && ((f[0] === "FILE_SRC" && f.length === 5) || (f[0] === "FILE_CONFLICT" && f.length === 6)) && relative(f[1]) && f.slice(2).every(v => decimal.test(v))) declarations.push({ path: f[1], status: f[0] === "FILE_SRC" ? "RP" : "CO" });
|
|
27
|
+
else return null;
|
|
28
|
+
}
|
|
29
|
+
if (!declarations.length || declarations.length !== actions.length || new Set(declarations.map(d => d.path.toLowerCase())).size !== declarations.length || new Set(actions.map(a => copiedPathKey(a.path))).size !== actions.length) return null;
|
|
30
|
+
// Only the three captured declaration combinations are sourced here. Copied-only
|
|
31
|
+
// cardinalities continue through the existing independent route.
|
|
32
|
+
if (!["RP", "CO", "CO,CP,RP"].includes(declarations.map(d => d.status).sort().join(","))) return null;
|
|
33
|
+
// Absolute workspace scope is established by the pending observation below.
|
|
34
|
+
const result: MergedDeclaration[] = [];
|
|
35
|
+
for (const d of declarations) {
|
|
36
|
+
const matches = actions.filter(a => a.path.toLowerCase().endsWith(d.path.replaceAll("/", "\\").toLowerCase()) && a.status === (d.status === "CO" ? "merge" : "copy"));
|
|
37
|
+
if (matches.length !== 1) return null;
|
|
38
|
+
result.push({ ...d, actionPath: matches[0].path });
|
|
39
|
+
}
|
|
40
|
+
return new Set(result.map(d => copiedPathKey(d.actionPath))).size === actions.length ? result : null;
|
|
41
|
+
}
|
|
42
|
+
export function parseMergedPending(text: string, cwd: string): MergedPending | null {
|
|
43
|
+
const lines = text.split(/\r?\n/); if (lines.at(-1) === "") lines.pop();
|
|
44
|
+
if (!absolute(cwd) || lines.length < 2 || lines.length > 129 || Buffer.byteLength(text, "utf8") > 65536) return null;
|
|
45
|
+
const h = lines[0].split("\x1f");
|
|
46
|
+
if (h.length !== 4 || h[0] !== "STATUS" || !decimal.test(h[1]) || !h.slice(2).every(safe)) return null;
|
|
47
|
+
const rows: MergedPending["rows"] = []; let mergeChangeset = "";
|
|
48
|
+
for (const line of lines.slice(1)) {
|
|
49
|
+
const f = line.split("\x1f"), m = /^Merge from ([1-9][0-9]{0,19})$/.exec(f[4] ?? "");
|
|
50
|
+
if (f.length !== 5 || !/^(CO|RP|CP)$/.test(f[0]) || !absolute(f[1]) || f[2] !== "False" || !(f[0] === "CO" ? f[3] === "-1" : positive.test(f[3])) || !m) return null;
|
|
51
|
+
const rel = win32.relative(cwd, f[1]);
|
|
52
|
+
if (!rel || rel.startsWith("..") || win32.isAbsolute(rel) || rows.some(r => copiedPathKey(r.path) === copiedPathKey(f[1])) || mergeChangeset && mergeChangeset !== m[1]) return null;
|
|
53
|
+
mergeChangeset = m[1]; rows.push({ path: f[1], status: f[0] as MergedDeclaration["status"] });
|
|
54
|
+
}
|
|
55
|
+
if (!["CP", "RP", "CO", "CO,CP,RP"].includes(rows.map(r => r.status).sort().join(","))) return null;
|
|
56
|
+
return { changeset: h[1], repository: h[2], server: h[3], mergeChangeset, paths: rows.map(r => r.path), rows };
|
|
57
|
+
}
|
|
58
|
+
export function correlateMergedApply(declarations: MergedDeclaration[], pending: MergedPending, cwd: string): boolean {
|
|
59
|
+
return declarations.length === pending.rows.length && declarations.every(d => {
|
|
60
|
+
const key = copiedPathKey(win32.join(cwd, d.path));
|
|
61
|
+
return copiedPathKey(d.actionPath) === key && pending.rows.some(r => copiedPathKey(r.path) === key && r.status === d.status);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
65
|
+
export function admitMergedStandardStatus(text: string, pending: MergedPending, source: string, cwd: string): boolean {
|
|
66
|
+
const lines = text.split(/\r?\n/).filter(l => l.trim() !== ""), branch = parseSwitchLoadedBranch(text), requested = parseSwitchTarget(source);
|
|
67
|
+
if (!branch || !requested || branch.repository !== pending.repository || branch.server !== pending.server || !lines[0].endsWith(`(cs:${pending.changeset} - head)`) || lines[1] !== "Pending merge links") return false;
|
|
68
|
+
const link = /^ Merge from cs:([1-9][0-9]{0,19}) at (.+)$/.exec(lines[2] ?? ""), linked = link && parseSwitchTarget(link[2]);
|
|
69
|
+
if (!link || !linked || link[1] !== pending.mergeChangeset || linked.branch !== requested.branch || linked.repository !== pending.repository || linked.server !== pending.server || requested.repository !== null && (requested.repository !== linked.repository || requested.server !== linked.server)) return false;
|
|
70
|
+
let index = 3;
|
|
71
|
+
for (const section of ["Changed", "Added"]) {
|
|
72
|
+
const rows = pending.rows.filter(r => (r.status === "CP") === (section === "Added")); if (!rows.length) continue;
|
|
73
|
+
if (lines[index++] !== section || !/^ Status +Size +Last Modified +Path *$/.test(lines[index++] ?? "")) return false;
|
|
74
|
+
const remaining = new Set(rows);
|
|
75
|
+
for (let n = 0; n < rows.length; n++) {
|
|
76
|
+
const row = lines[index++] ?? "";
|
|
77
|
+
const matches = [...remaining].filter(r => {
|
|
78
|
+
const label = r.status === "CO" ? "Checked-out (changed)" : r.status === "RP" ? "Replaced" : "Copied (new)";
|
|
79
|
+
return new RegExp(`^ ${escape(label)} \\(Merge from ${pending.mergeChangeset}\\) {2,}[0-9]+ bytes {2,}[^\\r\\n\\x00-\\x1f\\x7f-\\x9f]{1,128}? {2,}${escape(win32.relative(cwd, r.path))} *$`).test(row);
|
|
80
|
+
});
|
|
81
|
+
if (matches.length !== 1) return false; remaining.delete(matches[0]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return index === lines.length;
|
|
85
|
+
}
|
|
@@ -13,7 +13,7 @@ export type MergeReceiptData = {
|
|
|
13
13
|
capability: { state: "not-checked" | "advertised" | "missing" | "unavailable"; missingTokens: string[] };
|
|
14
14
|
attempt: MergeAttempt;
|
|
15
15
|
capture: { help: MergeCapture | null; merge: MergeCapture | null };
|
|
16
|
-
parse: { records: number; malformed: boolean; unknownOperations: number; changesetsObserved: number; conflictsObserved: number } | null;
|
|
16
|
+
parse: { records: number; malformed: boolean; unknownOperations: number; changesetsObserved: number; conflictsObserved: number; warningsObserved: number } | null;
|
|
17
17
|
observedChangesets: ServerChangeset[];
|
|
18
18
|
conflictPaths: string[];
|
|
19
19
|
counts: { changesetsReturned: number; changesetsOmitted: number; conflictsReturned: number; conflictsOmitted: number };
|
package/src/domain/status-xml.ts
CHANGED
|
@@ -13,7 +13,7 @@ export class StatusXmlError extends Error {
|
|
|
13
13
|
}
|
|
14
14
|
function invalid(): never { throw new StatusXmlError(); }
|
|
15
15
|
function limit(): never { throw new StatusXmlError("output_overflow"); }
|
|
16
|
-
const kinds = { CH: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" } as const;
|
|
16
|
+
const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" } as const;
|
|
17
17
|
const fields = new Set(["Type", "TypeVerbose", "Path", "OldPath", "PrintableMovedPath", "MergesInfo", "SimilarityPerUnit", "Similarity", "Size", "PrintableSize", "PrintableLastModified", "RevisionType", "LastModified"]);
|
|
18
18
|
const isAbsolute = (path: string): boolean => path.startsWith("/") || /^[A-Za-z]:\\/.test(path) || /^\\\\[^\\]+\\[^\\]+\\/.test(path);
|
|
19
19
|
const identity = (value: string): string => {
|
|
@@ -95,6 +95,7 @@ export function parseStatusXml(bytes: Uint8Array): { output: string; items: XmlS
|
|
|
95
95
|
if (change.tagName !== "Change") invalid();
|
|
96
96
|
const row = children(change, fields);
|
|
97
97
|
// All recognized fields are scalar in the verified configuration.
|
|
98
|
+
// TypeVerbose/MergesInfo are display metadata, never merge-readiness evidence.
|
|
98
99
|
for (const value of row.values()) leaf(value);
|
|
99
100
|
const statusCode = leaf(row.get("Type"));
|
|
100
101
|
if (!/^[A-Z][A-Z0-9_]*$/.test(statusCode)) invalid();
|
|
@@ -104,7 +105,7 @@ export function parseStatusXml(bytes: Uint8Array): { output: string; items: XmlS
|
|
|
104
105
|
const oldPath = leaf(row.get("OldPath"));
|
|
105
106
|
const revisionType = leaf(row.get("RevisionType"));
|
|
106
107
|
if (!/^en[A-Za-z0-9]+$/.test(revisionType)) invalid();
|
|
107
|
-
const supportedType = revisionType === "enDirectory" || revisionType === "enTextFile";
|
|
108
|
+
const supportedType = revisionType === "enDirectory" || revisionType === "enTextFile" || revisionType === "enBinaryFile";
|
|
108
109
|
if (supportedStatus && (kind === "moved") !== Boolean(oldPath)) invalid();
|
|
109
110
|
if (oldPath && identity(oldPath) === path) invalid();
|
|
110
111
|
if (seen.has(path)) invalid();
|
|
@@ -21,7 +21,8 @@ export function parseSwitchTarget(value: string) {
|
|
|
21
21
|
}
|
|
22
22
|
export function parseSwitchLoadedBranch(text: string): SwitchBranchObservation | null {
|
|
23
23
|
const lines = text.split(/\r?\n/);
|
|
24
|
-
|
|
24
|
+
// A numeric head is an annotation, not the loaded changeset or branch owner.
|
|
25
|
+
const grammar = /^(\/[^@]+)@([^@]+)@([^\s]+) \(cs:(?:0|[1-9][0-9]{0,19}) - head(?::(?:0|[1-9][0-9]{0,19}))?\)$/u;
|
|
25
26
|
const match = grammar.exec(lines[0] ?? "");
|
|
26
27
|
if (!match || lines.slice(1).some(line => grammar.test(line)) || !hierarchy(match[1]) || !identity(match[2]) || !identity(match[3]) || !safeSwitchValue(`${match[1]}@${match[2]}@${match[3]}`) || !parseSwitchTarget(`${match[1]}@${match[2]}@${match[3]}`)) return null;
|
|
27
28
|
return { branch: match[1], repository: match[2], server: match[3], basis: "standard-status", scope: "loaded-workspace" };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseMergedApply, type MergedDeclaration } from "./merged-workspace";
|
|
1
2
|
import type { WorkspaceMergeAttempt, WorkspaceMergeCapture } from "../execution/workspace-merge-command";
|
|
2
3
|
export type { WorkspaceMergeAttempt, WorkspaceMergeCapture } from "../execution/workspace-merge-command";
|
|
3
4
|
export type MergeAction = "merge" | "finalize-merge";
|
|
@@ -6,9 +7,9 @@ export const safeMergeText = (v: unknown): v is string => typeof v === "string"
|
|
|
6
7
|
export type MergeStage = { attempt: WorkspaceMergeAttempt; capture: WorkspaceMergeCapture | null; admission: "not-observed" | "unsupported" | "admitted" };
|
|
7
8
|
export type WorkspaceMergeData = {
|
|
8
9
|
intendedArgv: readonly string[]; workingDirectory: string | null; requestedSource: string | null; strategy: MergeStrategy | null; cherrypicking: boolean | null; forced: boolean | null; previewRequested: boolean | null; outputFormatRequested: "text" | "json" | null;
|
|
9
|
-
apply: MergeStage; shortStatus: MergeStage; fullStatus: MergeStage; pendingReadMode: "short" | "copied-machine";
|
|
10
|
+
apply: MergeStage; shortStatus: MergeStage; fullStatus: MergeStage; pendingReadMode: "short" | "copied-machine" | "merged-machine";
|
|
10
11
|
commandCompleted: boolean; observedWorkspaceIdentity: null; observedSourceIdentity: null; observedFinalizedMetadata: null; observedPreservation: null; observedPendingItems: null;
|
|
11
|
-
fileConflictCount: number | null; protocol: "not-observed" | "already-connected" | "file-conflict" | "apply-add" | "apply-add-copied" | "unsupported";
|
|
12
|
+
fileConflictCount: number | null; protocol: "not-observed" | "already-connected" | "file-conflict" | "apply-add" | "apply-add-copied" | "merged-candidate" | "unsupported";
|
|
12
13
|
checkinReadiness: "unknown" | "blocked" | "supported-no-unresolved-signals"; verification: "unverified"; effect: "not-attempted" | "not-proven" | "uncertain";
|
|
13
14
|
};
|
|
14
15
|
type Header = { schemaVersion: 1; action: MergeAction; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: true }; data: WorkspaceMergeData };
|
|
@@ -19,7 +20,9 @@ export function buildWorkspaceMergeArgv(source: string, strategy: MergeStrategy,
|
|
|
19
20
|
if(cherry)result.push("--cherrypicking");if(forced)result.push("--forced");return result;
|
|
20
21
|
}
|
|
21
22
|
/** Narrow observed CM profile. Unknown records/text are not missing-conflict proof. */
|
|
22
|
-
export function admitWorkspaceMergeOutput(text: string, token: string): { protocol: WorkspaceMergeData["protocol"]; fileConflictCount: number | null; copiedPaths: string[]; addedPaths: string[] } {
|
|
23
|
+
export function admitWorkspaceMergeOutput(text: string, token: string): { protocol: WorkspaceMergeData["protocol"]; fileConflictCount: number | null; copiedPaths: string[]; addedPaths: string[]; declarations?: MergedDeclaration[] } {
|
|
24
|
+
const declarations = parseMergedApply(text, token);
|
|
25
|
+
if (declarations?.some(d => d.status !== "CP")) return { protocol: "merged-candidate", fileConflictCount: declarations.filter(d => d.status === "CO").length, copiedPaths: [], addedPaths: [], declarations };
|
|
23
26
|
const start=token+"S__",end=token+"E__",field=token+"F__";let rest=text,count=0,conflicts=0,connected=0,adds=0; const copiedPaths:string[]=[], addedPaths:string[]=[];
|
|
24
27
|
while(rest.trim()!==""){
|
|
25
28
|
if(++count>256)return {protocol:"unsupported",fileConflictCount:null,copiedPaths:[],addedPaths:[]};const s=rest.indexOf(start),e=rest.indexOf(end,s+start.length);
|
|
@@ -3,6 +3,7 @@ import { parseSwitchTarget, parseSwitchLoadedBranch, compareSwitchTarget, parseS
|
|
|
3
3
|
import { checkinSafeComment, checkinSafeValue } from "../domain/checkin-contract";
|
|
4
4
|
import { admitCleanStandardStatus } from "../domain/workspace-merge-contract";
|
|
5
5
|
import { parseCopiedPending } from "../domain/copied-merge";
|
|
6
|
+
import { parseMergedPending, admitMergedStandardStatus, type MergedPending } from "../domain/merged-workspace";
|
|
6
7
|
import { cmWhereEquals } from "../domain/branches";
|
|
7
8
|
import { buildCloseoutParentArgv, CLOSEOUT_STAGE_NAMES, closeoutChildEffect, closeoutCaptures, type CloseoutData, type CloseoutReceipt, type CloseoutRequest, type CloseoutStageName, type CloseoutRead, type CloseoutStage } from "../domain/closeout-contract";
|
|
8
9
|
import { captureWorkspaceMergeCommand, emptyWorkspaceMergeAttempt } from "../execution/workspace-merge-command";
|
|
@@ -29,7 +30,7 @@ export async function assembleCloseoutReceipt(input:unknown):Promise<CloseoutRec
|
|
|
29
30
|
};
|
|
30
31
|
const push=(s:CloseoutStage)=>{if(data.stages.length>=16)throw Error();data.stages.push(s);data.unattemptedStages=data.unattemptedStages.filter(n=>n!==s.name);};
|
|
31
32
|
const read=async(name:CloseoutStageName,argv:string[])=>{stage=name;const s:CloseoutRead={argv:[...argv],attempt:{...emptyWorkspaceMergeAttempt(),state:"unknown"},capture:null,admission:"unsupported",branch:null,parentPath:null,emptyParentResult:false,summary:null};push({name,kind:"read",result:s});const o=await captureWorkspaceMergeCommand([...argv],data.requested!.workdir);s.attempt=o.attempt;s.capture=o.capture;const success=o.capture.complete&&!o.failed&&o.attempt.state==="started"&&o.attempt.terminal==="observed"&&o.attempt.exitCode===0&&!o.attempt.aborted&&!o.attempt.timedOut&&o.stderr===""&&o.stdout!==null;return {s,o,success};};
|
|
32
|
-
const loaded=async(name:CloseoutStageName,verify=false)=>{const {s,o,success}=await read(name,["status"]);if(success){s.branch=parseSwitchLoadedBranch(o.stdout!);if(s.branch)s.admission="admitted";}if(!s.branch)return false;if(verify){data.targetVerification=compareSwitchTarget(s.branch,data.targetBranch!);if(data.targetVerification==="unverified")return false;}return true;};
|
|
33
|
+
const loaded=async(name:CloseoutStageName,verify=false,mergedPending?:MergedPending|null)=>{const {s,o,success}=await read(name,["status"]);if(success){s.branch=parseSwitchLoadedBranch(o.stdout!);if(s.branch&&(!mergedPending||admitMergedStandardStatus(o.stdout!,mergedPending,data.sourceBranch!,data.requested!.workdir)))s.admission="admitted";else s.branch=null;}if(!s.branch)return false;if(verify){data.targetVerification=compareSwitchTarget(s.branch,data.targetBranch!);if(data.targetVerification==="unverified")return false;}return true;};
|
|
33
34
|
const pending=async(name:"pending-before"|"pending-final")=>{const {s,o,success}=await read(name,[...switchPendingArgv]);if(success){const p=parseSwitchPending(o.stdout!,data.requested!.workdir);if(p.summary){s.admission="admitted";s.summary=p.summary;}}return s.summary;};
|
|
34
35
|
try{
|
|
35
36
|
const submitted=input&&typeof input==="object"&&!Array.isArray(input)?Object.freeze({...input}):input;admitRequest(submitted);const cwd=submitted.workdir??process.cwd();if(!safeSwitchValue(cwd)||!safeSwitchValue(resolve(cwd)))return finish("failed","invalid_request","Closeout needs a bounded workdir.");data.requested={source:submitted.source??null,target:submitted.target??null,message:submitted.message??null,cardRef:submitted.cardRef??null,strategy:submitted.strategy??"auto",updateTarget:submitted.updateTarget??true,includePrivate:submitted.includePrivate??false,preflight:submitted.preflight??false,outputFormat:submitted.format??"text",workdir:cwd};
|
|
@@ -60,9 +61,13 @@ export async function assembleCloseoutReceipt(input:unknown):Promise<CloseoutRec
|
|
|
60
61
|
if(data.requested.updateTarget){stage="update";childEntered=true;const updated=await assembleUpdateReceipt({workdir:cwd});push({name:"update",kind:"update",result:updated});childEntered=false;if(!updated.ok)return finish("failed","child_failed");}
|
|
61
62
|
if(!await loaded("loaded-before-merge",true))return finish("failed","target_unverified");
|
|
62
63
|
stage="merge";childEntered=true;const merged=await assembleWorkspaceMergeReceipt("merge",{source:data.sourceBranch!,strategy:data.requested.strategy,workdir:cwd});push({name:"merge",kind:"merge",result:merged});childEntered=false;if(!merged.ok)return finish("failed","child_failed");
|
|
63
|
-
const copiedReady=merged.data.protocol==="apply-add-copied";
|
|
64
|
-
const ready=await read("readiness",copiedReady?[...switchPendingArgv]:["status"]);
|
|
65
|
-
|
|
64
|
+
const copiedReady=merged.data.protocol==="apply-add-copied",mergedReady=merged.data.protocol==="merged-candidate";
|
|
65
|
+
const ready=await read("readiness",copiedReady||mergedReady?[...switchPendingArgv]:["status"]);
|
|
66
|
+
const mergedPending=mergedReady&&ready.success?parseMergedPending(ready.o.stdout!,cwd):null;
|
|
67
|
+
if(!ready.success||ready.o.stderr!==""||!(mergedReady?!!mergedPending:copiedReady?!!parseCopiedPending(ready.o.stdout!,cwd):admitCleanStandardStatus(ready.o.stdout!)))return finish("failed","observation_failed","Independent post-merge readiness is outside the admitted profile; no checkin permitted. Prior effects remain possible.");ready.s.admission="admitted";
|
|
68
|
+
// The independent machine snapshot is not readiness proof by itself: the next
|
|
69
|
+
// full status must still correlate its source link, namespace and resolved rows.
|
|
70
|
+
if(!await loaded("loaded-before-checkin",true,mergedPending))return finish("failed","target_unverified");
|
|
66
71
|
const checkinArgs={message:data.checkinMessage,includeAll:true,includePrivate:data.requested.includePrivate,workdir:cwd};
|
|
67
72
|
stage="checkin-preflight";childEntered=true;const preview=await assembleCheckinReceipt({...checkinArgs,preflight:true});push({name:"checkin-preflight",kind:"checkin",result:preview});childEntered=false;if(!preview.ok)return finish("failed","child_failed");
|
|
68
73
|
stage="checkin";childEntered=true;const checked=await assembleCheckinReceipt(checkinArgs);push({name:"checkin",kind:"checkin",result:checked});childEntered=false;data.createdChangeset=checked.data.createdChangeset;if(!checked.ok)return finish("failed","child_failed");
|
|
@@ -88,9 +88,9 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
|
|
|
88
88
|
const obs = await captureServerMergeCommand(command, SERVER_MERGE_COMMAND_TIMEOUT_MS); data.attempt = obs.attempt; data.capture.merge = obs.capture;
|
|
89
89
|
completeness.capture = obs.capture.complete ? "complete" : "incomplete";
|
|
90
90
|
if (obs.attempt.state === "not-attempted" || obs.attempt.state === "not-started") return finish("failed", "merge", obs.attempt.aborted ? "aborted" : "launch_failed");
|
|
91
|
-
const parsed = parseServerMergeOutput(obs.stdout ?? "", seps);
|
|
91
|
+
const parsed = parseServerMergeOutput(obs.stdout ?? "", seps, data.mode);
|
|
92
92
|
const paths = parsed.records.filter(r => r.operation === "FILE_CONFLICT" && r.fields.length === 6 && r.fields[1]!.length <= 4096 && !serverMergeControlPattern.test(r.fields[1]!)).map(r => r.fields[1]!);
|
|
93
|
-
data.parse = { records: parsed.records.length, malformed: parsed.malformed, unknownOperations: parsed.unknownOperations.length, changesetsObserved: parsed.changesets.length, conflictsObserved: paths.length };
|
|
93
|
+
data.parse = { records: parsed.records.length, malformed: parsed.malformed, unknownOperations: parsed.unknownOperations.length, changesetsObserved: parsed.changesets.length, conflictsObserved: paths.length, warningsObserved: parsed.warningsObserved };
|
|
94
94
|
// One aggregate evidence budget, not one budget per collection.
|
|
95
95
|
data.observedChangesets = parsed.changesets.slice(0, 100); data.conflictPaths = paths.slice(0, 100 - data.observedChangesets.length);
|
|
96
96
|
data.counts = { changesetsReturned: data.observedChangesets.length, changesetsOmitted: parsed.changesets.length - data.observedChangesets.length, conflictsReturned: data.conflictPaths.length, conflictsOmitted: paths.length - data.conflictPaths.length };
|
|
@@ -104,7 +104,8 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
|
|
|
104
104
|
data.remoteAnalysis = "performed";
|
|
105
105
|
return finish("analyzed", "merge");
|
|
106
106
|
}
|
|
107
|
-
|
|
107
|
+
const reason = parsed.malformed ? "Malformed server analysis framing or fields." : parsed.unknownOperations.length ? "Unsupported server analysis operation or subtype." : "Server conflict analysis could not be verified.";
|
|
108
|
+
return finish("uncertain", "merge", "uncertain", reason + " No applying command was requested.");
|
|
108
109
|
}
|
|
109
110
|
if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && matching.length === 1 && !parsed.hasConflict && !parsed.isAlreadyConnected) { data.createdChangeset = matching[0]!; return finish("completed", "merge"); }
|
|
110
111
|
if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && parsed.isAlreadyConnected && !parsed.changesets.length && !parsed.hasConflict) return finish("no-op", "merge");
|
|
@@ -119,7 +120,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
|
|
|
119
120
|
}
|
|
120
121
|
export async function presentServerMergeReceipt(dto: MergeReceipt, format: "text" | "json" = "text"): Promise<string> {
|
|
121
122
|
const title = dto.outcome === "no-op" ? "No-op" : dto.outcome[0]!.toUpperCase() + dto.outcome.slice(1);
|
|
122
|
-
const text = dto.outcome === "preflight" ? `## Server Merge Preflight\n\n- Would run: yes\n- Remote analysis: not performed\n- Command argv: ${JSON.stringify(dto.data.command)}` : dto.outcome === "analyzed" ? `## Server Merge Analysis\n\n- Read-only: no applying merge requested\n- File conflicts observed: ${dto.data.parse?.conflictsObserved ?? "unknown"}\n${dto.data.conflictPaths.map(path => "- " + path).join("\n")}\n- Conflicts omitted: ${dto.data.counts.conflictsOmitted}\n- Point-in-time evidence only; no contributor content retrieved.` : `## Server Merge ${title}\n\n${dto.data.createdChangeset ? "- Created target changeset: cs:" + dto.data.createdChangeset.id + "\n" : ""}- Effect: ${dto.data.effect}\n- Server alias equivalence, merge-link identity and Xlink effects: unverified\n${dto.ok ? "" : "- Do not retry automatically; inspect server state if an attempt may have started."}`;
|
|
123
|
+
const text = dto.outcome === "preflight" ? `## Server Merge Preflight\n\n- Would run: yes\n- Remote analysis: not performed\n- Command argv: ${JSON.stringify(dto.data.command)}` : dto.outcome === "analyzed" ? `## Server Merge Analysis\n\n- Read-only: no applying merge requested\n- File conflicts observed: ${dto.data.parse?.conflictsObserved ?? "unknown"}\n${dto.data.conflictPaths.map(path => "- " + path).join("\n")}\n- Conflicts omitted: ${dto.data.counts.conflictsOmitted}\n- Discard CHG warnings observed: ${dto.data.parse?.warningsObserved ?? "unknown"} (content equality unverified)\n- Point-in-time evidence only; no contributor content retrieved.` : `## Server Merge ${title}\n\n${dto.data.createdChangeset ? "- Created target changeset: cs:" + dto.data.createdChangeset.id + "\n" : ""}- Effect: ${dto.data.effect}\n- Server alias equivalence, merge-link identity and Xlink effects: unverified\n${dto.ok ? "" : "- " + dto.error.message + "\n- Do not retry automatically; inspect server state if an attempt may have started."}`;
|
|
123
124
|
const rendered = await formatServerMergeResult(format, dto.outcome, text, dto.data);
|
|
124
125
|
return rendered.length <= 24000 ? rendered : JSON.stringify({ action: dto.action, outcome: dto.outcome, ok: dto.ok, note: "Presentation omitted; use the registered tool structuredContent for the bounded receipt." });
|
|
125
126
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseMergedPending, correlateMergedApply, admitMergedStandardStatus } from "../domain/merged-workspace";
|
|
1
2
|
import { randomBytes } from "node:crypto";
|
|
2
3
|
import { win32 } from "node:path";
|
|
3
4
|
import { parseCopiedPending, admitCopiedStandardStatus, copiedPendingArgv, copiedPathKey } from "../domain/copied-merge";
|
|
@@ -22,21 +23,23 @@ export async function assembleWorkspaceMergeReceipt(action:MergeAction,args:unkn
|
|
|
22
23
|
active="apply";const apply=await captureWorkspaceMergeCommand([...data.intendedArgv],cwd);data.apply={attempt:apply.attempt,capture:apply.capture,admission:"unsupported"};active=null;data.effect=["started","unknown"].includes(apply.attempt.state)?"uncertain":"not-attempted";
|
|
23
24
|
if(!apply.capture.complete||apply.failed||apply.attempt.state!=="started")return workspaceMergeFailure(action,data,apply.attempt.aborted?"aborted":data.effect==="not-attempted"?"launch_failed":"uncertain","Merge capture/start/completion is not established; do not retry automatically.");
|
|
24
25
|
data.commandCompleted=apply.attempt.exitCode===0;
|
|
25
|
-
const parsed=admitWorkspaceMergeOutput(apply.stdout!,token);data.protocol=parsed.protocol;data.pendingReadMode=parsed.protocol==="apply-add-copied"?"copied-machine":"short";data.fileConflictCount=parsed.fileConflictCount;data.apply.admission=parsed.protocol==="unsupported"?"unsupported":"admitted";
|
|
26
|
+
const parsed=admitWorkspaceMergeOutput(apply.stdout!,token);data.protocol=parsed.protocol;data.pendingReadMode=parsed.protocol==="merged-candidate"?"merged-machine":parsed.protocol==="apply-add-copied"?"copied-machine":"short";data.fileConflictCount=parsed.fileConflictCount;data.apply.admission=parsed.protocol==="unsupported"?"unsupported":"admitted";
|
|
26
27
|
if(parsed.protocol==="file-conflict"){data.checkinReadiness="blocked";return workspaceMergeFailure(action,data,"conflict","Observed file-conflict records; checkin blocked. Earlier effects are not disproven.");}
|
|
27
28
|
if(!data.commandCompleted)return workspaceMergeFailure(action,data,"uncertain","Merge exited nonzero; workspace effects may already exist.");
|
|
28
29
|
if(parsed.protocol==="unsupported")return workspaceMergeFailure(action,data,"unsupported_readiness","Merge completed; applying profile unadmitted. Inspect apply.admission/protocol; verify loaded package revision and retain sanitized producer evidence. Readiness unknown; do not reapply automatically or infer rollback.");
|
|
29
|
-
const copied=parsed.protocol==="apply-add-copied";data.pendingReadMode=copied?"copied-machine":"short";
|
|
30
|
-
if(
|
|
31
|
-
|
|
30
|
+
const merged=parsed.protocol==="merged-candidate";const copied=parsed.protocol==="apply-add-copied";data.pendingReadMode=merged?"merged-machine":copied?"copied-machine":"short";
|
|
31
|
+
if(merged&&(strategy!=="auto"||cherry||forced))return workspaceMergeFailure(action,data,"unsupported_readiness","Merged readiness is sourced only for automatic non-forced, non-cherrypicked apply; do not reapply automatically.");
|
|
32
|
+
if((copied||merged)&&process.platform!=="win32")return workspaceMergeFailure(action,data,"unsupported_readiness","Copied readiness is sourced only on the observed Windows client.");
|
|
33
|
+
active="shortStatus";const short=await captureWorkspaceMergeCommand((copied||merged)?[...copiedPendingArgv]:["status","--short"],cwd);data.shortStatus={attempt:short.attempt,capture:short.capture,admission:"unsupported"};active=null;
|
|
32
34
|
if(!short.capture.complete||short.failed||short.attempt.exitCode!==0)return workspaceMergeFailure(action,data,"unsupported_readiness","Merge command completed; pending read failed/incomplete. Checkin readiness unknown.");
|
|
35
|
+
const mergedPending=merged?parseMergedPending(short.stdout!,cwd):null;
|
|
33
36
|
const pending=copied?parseCopiedPending(short.stdout!,cwd):null;
|
|
34
37
|
const copiedKeys=pending?.paths.map(copiedPathKey)??[];
|
|
35
38
|
const coherent=!!pending&&parsed.copiedPaths.length===copiedKeys.length&&new Set(parsed.copiedPaths.map(copiedPathKey)).size===copiedKeys.length&&parsed.copiedPaths.every(p=>copiedKeys.includes(copiedPathKey(p)))&&new Set(parsed.addedPaths.map(p=>copiedPathKey(win32.join(cwd,p)))).size===copiedKeys.length&&parsed.addedPaths.every(p=>p.startsWith("/")&&!p.includes("\\")&&p.slice(1).split("/").every(v=>v!==""&&v!=="."&&v!==".."&&!/[?\uFFFD]/u.test(v))&&copiedKeys.includes(copiedPathKey(win32.join(cwd,p))));
|
|
36
|
-
data.shortStatus.admission=(copied?coherent:short.stdout==="")&&short.stderr===""?"admitted":"unsupported";
|
|
39
|
+
data.shortStatus.admission=(merged?!!mergedPending&&correlateMergedApply(parsed.declarations!,mergedPending,cwd):copied?coherent:short.stdout==="")&&short.stderr===""?"admitted":"unsupported";
|
|
37
40
|
active="fullStatus";const full=await captureWorkspaceMergeCommand(["status"],cwd);data.fullStatus={attempt:full.attempt,capture:full.capture,admission:"unsupported"};active=null;
|
|
38
41
|
if(!full.capture.complete||full.failed||full.attempt.exitCode!==0)return workspaceMergeFailure(action,data,"unsupported_readiness","Merge command completed; merge-state read failed/incomplete. Checkin readiness unknown.");
|
|
39
|
-
data.fullStatus.admission=(copied?!!pending&&admitCopiedStandardStatus(full.stdout!,pending,source,cwd):admitCleanStandardStatus(full.stdout!))&&full.stderr===""?"admitted":"unsupported";
|
|
42
|
+
data.fullStatus.admission=(merged?!!mergedPending&&admitMergedStandardStatus(full.stdout!,mergedPending,source,cwd):copied?!!pending&&admitCopiedStandardStatus(full.stdout!,pending,source,cwd):admitCleanStandardStatus(full.stdout!))&&full.stderr===""?"admitted":"unsupported";
|
|
40
43
|
// No warning/unknown stderr can authorize compound checkin.
|
|
41
44
|
if(apply.stderr!=="")return workspaceMergeFailure(action,data,"unsupported_readiness","Merge command completed; applying stderr is outside the admitted warning profile. Checkin paused; prior effects remain possible. Do not reapply automatically.");
|
|
42
45
|
if(data.shortStatus.admission!=="admitted")return workspaceMergeFailure(action,data,"unsupported_readiness",copied?"Merge command completed; copied pending/ADD/path cardinality correlation is unadmitted (shortStatus). Checkin paused; inspect separately authorized pending evidence without reapplying merge.":"Merge command completed; shortStatus pending/warning evidence is unadmitted. Checkin paused; inspect separately authorized pending evidence without reapplying merge.");
|
package/src/pi/checkin-output.ts
CHANGED
|
@@ -13,7 +13,7 @@ const capture = object({ stdoutBytes: uint(), stderrBytes: uint(), stdoutRetaine
|
|
|
13
13
|
const step = object({ name: enumOf(["pending-before", "initial-checkin", "private-add", "private-retry", "pending-recovery", "fallback-checkin", "pending-after", "merge-diagnostic"]), operation: enumOf(["status", "checkin", "add"]), attempt, capture, effect: enumOf(["not-attempted", "uncertain", "command-completed", "changeset-created", "read-observed"]), evidence: enumOf(["unadmitted", "admitted"]), records: uint(501), summary: nullable(summary), changeset: nullable(cs) });
|
|
14
14
|
const paths = Type.Array(str(), { maxItems: 100 });
|
|
15
15
|
const reasons = Type.Array(object({ path: str(), reason: str(256) }), { maxItems: 100 });
|
|
16
|
-
const data = { sourceAdmission: Type.Literal("windows-cm11.0.16.10371-observed"), requestedPaths: paths, includedPaths: paths, fallbackPaths: paths, excludedPaths: reasons, privateAddPaths: paths, blockedPrivatePaths: reasons, itemEvents: Type.Array(Type.Union([object({ operation: enumOf(["CO", "AD", "DE", "CP"]), path: str() }), object({ operation: Type.Literal("MV"), sourcePath: str(), path: str() })]), { maxItems: 100 }), observedChangesets: Type.Array(cs, { maxItems: 100 }), command: nullable(Type.Array(str(8192), { maxItems: 120 })), steps: Type.Array(step, { maxItems: 8 }), autoEnabledApplyChanged: Type.Boolean(), usedFallbackRetry: Type.Boolean(), usedPrivateAutoAddRecovery: Type.Boolean(), pendingBefore: nullable(summary), pendingAfter: nullable(summary), wouldRun: Type.Boolean(), omittedReferences: uint(10000), scopeExhaustion: Type.Literal("unverified"), branchHead: Type.Literal("unverified"), serverAliasEquivalence: Type.Literal("unverified"), xlinkEffects: Type.Literal("unverified"), mergeLinkIdentity: Type.Literal("unverified") };
|
|
16
|
+
const data = { sourceAdmission: Type.Literal("windows-cm11.0.16.10371-observed"), requestedPaths: paths, includedPaths: paths, fallbackPaths: paths, excludedPaths: reasons, privateAddPaths: paths, blockedPrivatePaths: reasons, itemEvents: Type.Array(Type.Union([object({ operation: enumOf(["CO", "AD", "DE", "CP", "RP"]), path: str() }), object({ operation: Type.Literal("MV"), sourcePath: str(), path: str() })]), { maxItems: 100 }), observedChangesets: Type.Array(cs, { maxItems: 100 }), command: nullable(Type.Array(str(8192), { maxItems: 120 })), steps: Type.Array(step, { maxItems: 8 }), autoEnabledApplyChanged: Type.Boolean(), usedFallbackRetry: Type.Boolean(), usedPrivateAutoAddRecovery: Type.Boolean(), pendingBefore: nullable(summary), pendingAfter: nullable(summary), wouldRun: Type.Boolean(), omittedReferences: uint(10000), scopeExhaustion: Type.Literal("unverified"), branchHead: Type.Literal("unverified"), serverAliasEquivalence: Type.Literal("unverified"), xlinkEffects: Type.Literal("unverified"), mergeLinkIdentity: Type.Literal("unverified") };
|
|
17
17
|
const header = { schemaVersion: Type.Literal(1), action: Type.Literal("checkin"), provenance: object({ source: Type.Literal("plastic"), producer: Type.Literal("@aefree/pi-plastic"), contentTrust: Type.Literal("external") }), completeness: object({ capture: enumOf(["complete", "incomplete", "unknown"]), projection: Type.Boolean() }) };
|
|
18
18
|
const error = object({ code: str(64), stage: enumOf(["input", "pending", "checkin", "producer"]), message: str(512) });
|
|
19
19
|
export const checkinOutputSchema = Type.Unsafe<CheckinReceipt>(Type.Union([
|
|
@@ -58,7 +58,7 @@ export function validateCloseoutOutput(value:unknown):value is CloseoutReceipt {
|
|
|
58
58
|
for(const [index,s] of stages.entries()) {
|
|
59
59
|
if(s.kind==="read") {
|
|
60
60
|
if(!validateRead(s))return false;
|
|
61
|
-
if(s.name==="readiness"){const merge=stages.slice(0,index).find(x=>x.kind==="merge");if(!merge||merge.kind!=="merge"||!same(s.result.argv,merge.result.data.protocol
|
|
61
|
+
if(s.name==="readiness"){const merge=stages.slice(0,index).find(x=>x.kind==="merge");if(!merge||merge.kind!=="merge"||!same(s.result.argv,["apply-add-copied","merged-candidate"].includes(merge.result.data.protocol)?[...switchPendingArgv]:["status"]))return false;}
|
|
62
62
|
if(s.name==="parent"){if(!d.sourceBranch||!same(s.result.argv,buildCloseoutParentArgv(d.sourceBranch)))return false;const src=parseSwitchTarget(d.sourceBranch)!;if(s.result.parentPath&&(s.result.parentPath!==src.branch.slice(0,src.branch.lastIndexOf("/"))||!s.result.capture?.stdoutBytes))return false;if(s.result.emptyParentResult&&s.result.capture?.stdoutBytes!==0)return false;if(index<stages.length-1&&(s.result.emptyParentResult||!s.result.parentPath||stages[index+1].name!=="pending-before"))return false;}
|
|
63
63
|
if(index<stages.length-1&&s.result.admission!=="admitted")return false;
|
|
64
64
|
if(s.name.startsWith("loaded-")&&s.name!=="loaded-before"&&s.result.branch){if(!d.targetBranch)return false;lastVerification=compareSwitchTarget(s.result.branch,d.targetBranch);if(index<stages.length-1&&lastVerification==="unverified")return false;}
|
|
@@ -18,7 +18,7 @@ const data = {
|
|
|
18
18
|
command: nullable(Type.Array(str(8192), { maxItems: 16 })),
|
|
19
19
|
capability: object({ state: enumOf(["not-checked", "advertised", "missing", "unavailable"]), missingTokens: Type.Array(str(64), { maxItems: 9 }) }),
|
|
20
20
|
attempt, capture: object({ help: nullable(capture), merge: nullable(capture) }),
|
|
21
|
-
parse: nullable(object({ records: uint(500), malformed: Type.Boolean(), unknownOperations: uint(500), changesetsObserved: uint(500), conflictsObserved: uint(500) })),
|
|
21
|
+
parse: nullable(object({ records: uint(500), malformed: Type.Boolean(), unknownOperations: uint(500), changesetsObserved: uint(500), conflictsObserved: uint(500), warningsObserved: uint(500) })),
|
|
22
22
|
observedChangesets: Type.Array(changeset, { maxItems: 100 }), conflictPaths: Type.Array(str(), { maxItems: 100 }),
|
|
23
23
|
counts: object({ changesetsReturned: uint(100), changesetsOmitted: uint(500), conflictsReturned: uint(100), conflictsOmitted: uint(500) }),
|
|
24
24
|
serverAliasEquivalence: Type.Literal("unverified"), mergeLinkIdentity: Type.Literal("unverified"), xlinkEffects: Type.Literal("unverified"), wouldRun: Type.Boolean(), remoteAnalysis: enumOf(["not-performed", "performed"]),
|
|
@@ -39,6 +39,7 @@ export function validateServerMergeOutput(value: unknown): value is MergeReceipt
|
|
|
39
39
|
const safeText = (s: string) => !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
|
|
40
40
|
for (const cs of [...d.observedChangesets, ...(d.createdChangeset ? [d.createdChangeset] : [])]) if (!splitServerBranch("br:" + cs.branch + "@" + cs.repository + "@" + cs.server) || ![cs.branch, cs.repository, cs.server, cs.mount].every(safeText)) return false;
|
|
41
41
|
if (!d.conflictPaths.every(s => s.startsWith("/") && safeText(s))) return false;
|
|
42
|
+
if (p && (p.warningsObserved > p.records || d.mode === "apply" && p.warningsObserved > 0 && ["completed", "no-op", "conflict"].includes(dto.outcome))) return false;
|
|
42
43
|
if (!p && (d.observedChangesets.length || d.conflictPaths.length || Object.values(d.counts).some(Boolean))) return false;
|
|
43
44
|
if (dto.completeness.capture === "complete" && !(d.capture.merge?.complete ?? d.capture.help?.complete)) return false;
|
|
44
45
|
if (dto.outcome === "preflight" && dto.completeness.capture !== "unknown") return false;
|
package/src/pi/status-output.ts
CHANGED
|
@@ -30,7 +30,7 @@ const xmlCount = Type.Integer({ minimum: 0, maximum: 20000 });
|
|
|
30
30
|
const xmlSummary = { totalPending: xmlCount, added: xmlCount, changed: xmlCount, moved: xmlCount, deleted: xmlCount, other: Type.Literal(0), private: xmlCount, tracked: xmlCount };
|
|
31
31
|
const xmlPathPattern = `^(?:/|[A-Za-z]:\\\\|\\\\\\\\)[^\\u0000-\\u001f${process.platform === "win32" ? "?" : ""}]*$`;
|
|
32
32
|
const xmlItem = object({
|
|
33
|
-
statusCode: Type.Union([Type.Literal("CH"), Type.Literal("AD"), Type.Literal("DE"), Type.Literal("LD"), Type.Literal("MV"), Type.Literal("PR")]),
|
|
33
|
+
statusCode: Type.Union([Type.Literal("CH"), Type.Literal("CO"), Type.Literal("CP"), Type.Literal("RP"), Type.Literal("AD"), Type.Literal("DE"), Type.Literal("LD"), Type.Literal("MV"), Type.Literal("PR")]),
|
|
34
34
|
kind: Type.Union([Type.Literal("added"), Type.Literal("changed"), Type.Literal("moved"), Type.Literal("deleted"), Type.Literal("private")]),
|
|
35
35
|
path: Type.String({ minLength: 1, maxLength: 4096, pattern: xmlPathPattern }), isDirectory: Type.Boolean(),
|
|
36
36
|
sourcePath: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, pattern: xmlPathPattern })),
|
|
@@ -79,7 +79,7 @@ export function validateStatusOutput(value: unknown, schemaVersion: 1 | 2 = 1):
|
|
|
79
79
|
const data = checked.data;
|
|
80
80
|
const { parsed, returned, omitted, excluded, overCap } = data.itemCount;
|
|
81
81
|
const seen = new Set<string>();
|
|
82
|
-
const kinds = { CH: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" };
|
|
82
|
+
const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" };
|
|
83
83
|
const invalidRows = data.items.some(row => {
|
|
84
84
|
const unsafe = (path: string) => /[\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(path);
|
|
85
85
|
const invalid = unsafe(row.path) || (row.sourcePath !== undefined && unsafe(row.sourcePath)) || row.kind !== kinds[row.statusCode] || (row.kind === "moved") !== Boolean(row.sourcePath) || row.sourcePath === row.path || seen.has(row.path);
|
|
@@ -6,7 +6,7 @@ const attempt=obj({state:enm(["not-attempted","not-started","started","unknown"]
|
|
|
6
6
|
const capture=obj({stdoutBytes:uint(),stderrBytes:uint(),stdoutRetainedBytes:uint(65536),stderrRetainedBytes:uint(16384),truncated:Type.Boolean(),complete:Type.Boolean(),validUtf8:Type.Boolean()});
|
|
7
7
|
const stage=obj({attempt,capture:nil(capture),admission:enm(["not-observed","unsupported","admitted"])});
|
|
8
8
|
const text=Type.String({minLength:1,maxLength:4096});
|
|
9
|
-
const data=obj({intendedArgv:Type.Array(text,{minItems:1,maxItems:13}),workingDirectory:nil(text),requestedSource:nil(text),strategy:nil(enm(["auto","source","destination"])),cherrypicking:nil(Type.Boolean()),forced:nil(Type.Boolean()),previewRequested:nil(Type.Boolean()),outputFormatRequested:nil(enm(["text","json"])),apply:stage,shortStatus:stage,fullStatus:stage,pendingReadMode:enm(["short","copied-machine"]),commandCompleted:Type.Boolean(),observedWorkspaceIdentity:Type.Null(),observedSourceIdentity:Type.Null(),observedFinalizedMetadata:Type.Null(),observedPreservation:Type.Null(),observedPendingItems:Type.Null(),fileConflictCount:nil(uint(256)),protocol:enm(["not-observed","already-connected","file-conflict","apply-add","apply-add-copied","unsupported"]),checkinReadiness:enm(["unknown","blocked","supported-no-unresolved-signals"]),verification:Type.Literal("unverified"),effect:enm(["not-attempted","not-proven","uncertain"])});
|
|
9
|
+
const data=obj({intendedArgv:Type.Array(text,{minItems:1,maxItems:13}),workingDirectory:nil(text),requestedSource:nil(text),strategy:nil(enm(["auto","source","destination"])),cherrypicking:nil(Type.Boolean()),forced:nil(Type.Boolean()),previewRequested:nil(Type.Boolean()),outputFormatRequested:nil(enm(["text","json"])),apply:stage,shortStatus:stage,fullStatus:stage,pendingReadMode:enm(["short","copied-machine","merged-machine"]),commandCompleted:Type.Boolean(),observedWorkspaceIdentity:Type.Null(),observedSourceIdentity:Type.Null(),observedFinalizedMetadata:Type.Null(),observedPreservation:Type.Null(),observedPendingItems:Type.Null(),fileConflictCount:nil(uint(256)),protocol:enm(["not-observed","already-connected","file-conflict","apply-add","apply-add-copied","merged-candidate","unsupported"]),checkinReadiness:enm(["unknown","blocked","supported-no-unresolved-signals"]),verification:Type.Literal("unverified"),effect:enm(["not-attempted","not-proven","uncertain"])});
|
|
10
10
|
export const workspaceMergeOutputSchema=Type.Unsafe<WorkspaceMergeReceipt>(Type.Union([
|
|
11
11
|
obj({schemaVersion:Type.Literal(1),action:enm(["merge","finalize-merge"]),provenance:obj({source:Type.Literal("plastic"),producer:Type.Literal("@aefree/pi-plastic"),contentTrust:Type.Literal("external")}),completeness:obj({capture:enm(["complete","incomplete","unknown"]),projection:Type.Literal(true)}),data,ok:Type.Literal(true),outcome:enm(["preflight","command-completed"])}),
|
|
12
12
|
obj({schemaVersion:Type.Literal(1),action:enm(["merge","finalize-merge"]),provenance:obj({source:Type.Literal("plastic"),producer:Type.Literal("@aefree/pi-plastic"),contentTrust:Type.Literal("external")}),completeness:obj({capture:enm(["complete","incomplete","unknown"]),projection:Type.Literal(true)}),data,ok:Type.Literal(false),outcome:enm(["failed","uncertain","blocked"]),error:obj({code:enm(["invalid_request","launch_failed","aborted","uncertain","unsupported_readiness","conflict","producer_failed"]),message:Type.String({minLength:1,maxLength:256})})})
|
|
@@ -31,12 +31,15 @@ export function validateWorkspaceMergeOutput(v:unknown):v is WorkspaceMergeRecei
|
|
|
31
31
|
if(d.commandCompleted!==Boolean(d.apply.capture?.complete&&d.apply.attempt.state==="started"&&d.apply.attempt.exitCode===0))return false;
|
|
32
32
|
if(d.shortStatus.admission==="admitted"&&((d.pendingReadMode==="short"?d.shortStatus.capture?.stdoutBytes!==0:!d.shortStatus.capture?.stdoutBytes)||d.shortStatus.capture?.stderrBytes!==0)||d.fullStatus.admission==="admitted"&&(!d.fullStatus.capture?.stdoutBytes||d.fullStatus.capture?.stderrBytes!==0))return false;
|
|
33
33
|
if(d.pendingReadMode==="copied-machine"&&d.protocol!=="apply-add-copied"||d.protocol==="apply-add-copied"&&d.pendingReadMode!=="copied-machine")return false;
|
|
34
|
-
|
|
34
|
+
if((d.pendingReadMode==="merged-machine")!==(d.protocol==="merged-candidate"))return false;
|
|
35
|
+
const later=stages.slice(1).some(s=>s.admission!=="not-observed");if(later&&(!d.commandCompleted||!["already-connected","apply-add","apply-add-copied","merged-candidate"].includes(d.protocol)))return false;
|
|
35
36
|
if(d.fullStatus.admission!=="not-observed"&&(!d.shortStatus.capture?.complete||d.shortStatus.attempt.exitCode!==0))return false;
|
|
36
37
|
if(d.protocol==="not-observed"&&(d.apply.admission==="admitted"||d.fileConflictCount!==null)||d.protocol==="unsupported"&&(d.fileConflictCount!==null||d.apply.admission!=="unsupported"))return false;
|
|
37
|
-
if(["already-connected","apply-add","apply-add-copied","file-conflict"].includes(d.protocol)&&d.apply.admission!=="admitted")return false;
|
|
38
|
+
if(["already-connected","apply-add","apply-add-copied","file-conflict","merged-candidate"].includes(d.protocol)&&d.apply.admission!=="admitted")return false;
|
|
39
|
+
if(d.protocol==="merged-candidate"&&(d.fileConflictCount===null||d.fileConflictCount>1||d.checkinReadiness==="blocked"))return false;
|
|
38
40
|
if(d.protocol==="file-conflict"?(d.fileConflictCount===null||d.fileConflictCount===0||d.checkinReadiness!=="blocked"):d.checkinReadiness==="blocked")return false;
|
|
39
41
|
if(["already-connected","apply-add","apply-add-copied"].includes(d.protocol)&&d.fileConflictCount!==0)return false;
|
|
42
|
+
if(d.protocol==="merged-candidate"&&d.checkinReadiness==="supported-no-unresolved-signals"&&(d.strategy!=="auto"||d.forced!==false||d.cherrypicking!==false))return false;
|
|
40
43
|
if(d.checkinReadiness==="supported-no-unresolved-signals"&&(!d.commandCompleted||!stages.every(s=>s.admission==="admitted"&&s.capture?.complete&&s.attempt.exitCode===0&&s.capture.stderrBytes===0)))return false;
|
|
41
44
|
if(d.previewRequested===true&&(!dto.ok||dto.outcome!=="preflight"||stages.some(s=>s.admission!=="not-observed")||d.effect!=="not-attempted"||d.commandCompleted||d.checkinReadiness!=="unknown"))return false;
|
|
42
45
|
if(dto.ok){if(dto.outcome==="preflight"&&d.previewRequested!==true)return false;if(dto.outcome==="command-completed"&&(d.previewRequested!==false||d.effect!=="not-proven"||d.checkinReadiness!=="supported-no-unresolved-signals"))return false;}
|