@aefree/pi-plastic 0.8.3 → 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 +13 -0
- package/docs/2026-10-06-merging.md +6 -5
- 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/merged-workspace.ts +85 -0
- 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/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/status-output.ts +2 -2
- package/src/pi/workspace-merge-output.ts +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ 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
|
+
|
|
10
23
|
## [0.8.3] - 2026-10-13
|
|
11
24
|
|
|
12
25
|
### Fixed
|
|
@@ -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
|
|
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 }));
|
|
@@ -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
|
+
}
|
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");
|
|
@@ -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;}
|
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;}
|