@aefree/pi-plastic 0.8.3 → 0.8.5

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 CHANGED
@@ -7,6 +7,29 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [0.8.5] - 2026-10-21
11
+
12
+ ### Fixed
13
+
14
+ - Correlate realistic repeated/mixed merged paths, added/deleted directories and KB/MB display through standalone checkin and compound closeout. Preserve unresolved controls, exact root identity, structural parent events and explicit disjoint private exclusions without retrying mutations.
15
+ - Admit captured reviewed result-file server ADD/RM profiles with exact target spelling, stable bounded input hashes and candidate/terminal distinction; retain sanitized unknown-operation classes and permit bounded CR/LF comments.
16
+ - Observe inferred local moves with fractional similarity and separate validated source/destination paths, without authorizing renames or widening mutation selection.
17
+ - Pair nested repeated merge basenames bijectively before exact workspace correlation, and inspect ordinary checkout state structurally rather than treating words in branch/file names as conflict markers.
18
+ - Explain patch backend/selector/output failures and loaded-base diff rejection stages without raw diagnostic leakage; retain bounded failure summaries in large JSON presentations and clarify the existing 4 MiB input limit.
19
+
20
+ ## [0.8.4] - 2026-10-20
21
+
22
+ ### Changed
23
+
24
+ - Validate against exact Pi 1.3.0 development dependencies; retain manifest-derived installed-version checks.
25
+
26
+ ### Fixed
27
+
28
+ - Admit observed behind-head branch headers for switching and relative branch creation without treating branch head as loaded content.
29
+ - Support observed binary and CO/CP/RP XML status records while retaining whole-document completeness, original status codes and unavailable base-revision identity.
30
+ - 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.
31
+ - Admit native RP checkin item events from the captured successful root profile; unknown framing, multiple changesets and unproven Xlink identity remain unsupported.
32
+
10
33
  ## [0.8.3] - 2026-10-13
11
34
 
12
35
  ### 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. Supported output can be followed by separately recorded short/full status reads. File-conflict records block. Exit 0 followed by failed/unsupported reads can retain commandCompleted=true with overall ok=false/uncertain and unknown readiness.
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 source admission is narrow:
37
+ Windows CM admission remains bounded; [observed profiles](2026-10-21-observed-workflow-profiles.md) document independent client captures and limits, 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: repeated/mixed replacement, text, copied-add and deletion records correlate structurally with controlled pending paths and source-linked full status. Captured added/deleted directory layouts, bytes/KB/MB and blank size cells are supported. Exit zero and FILE_CONFLICT/DO_MERGE alone never prove resolution; CO/NO_MERGES stops checkin.
42
43
 
43
- Unknown/mixed/malformed/empty applying output, other rich operations, unsupported localization, unrelated pending changes or merge links stop as unknown—not zero conflicts or rollback. Core rejection and independent closeout post-read gates prevent checkin on unknown readiness.
44
+ Unknown profiles, unrelated controlled work or extra links remain unknown—not zero conflicts or rollback. Explicitly excluded private work must be disjoint; expanded merged partial/private inclusion is unsupported. Forced/cherrypicked policies, moves, directory/binary conflicts, Xlinks and localization remain outside this profile.
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,13 +71,13 @@ 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-add admission does not admit mixed replace/delete profiles. A synthetic historical-shape regression proves the supported DO_COPIED/ADD/CP path, not historical recovery: unavailable historical producer bytes and uncertain/stale runtime loads remain unresolved.
74
+ Copied-only admission remains separate from the structurally correlated mixed/deletion route. Synthetic tests prove neither historical recovery nor loaded revision.
74
75
 
75
76
  ### Readiness and message admission
76
77
 
77
- Independent readiness requires complete successful capture, empty stderr, and an admitted header-only standard snapshot or the strict copied profile. Copied admission uses machine pending instead of the first standalone short read, then correlates full pending-link/Added/copied sections with requested source, namespace, changeset/merge metadata and paths. Volatile size/time display is opaque. Unknown/mixed/directory/duplicate/out-of-workspace records remain unsupported; CP selection keeps its CP code while using added kind locally in checkin. Final pending observations are typed and admitted, never swallowed into zero.
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/duplicate/out-of-scope rows stop; copied and deleted directories require their admitted layouts. Final pending reads never turn failure into zero.
78
79
 
79
- 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
+ Existing message/card trimming and explicit-message precedence remain. Derived messages must satisfy bounded comment admission before target mutation; interior CR/LF is supported, not other controls or malformed Unicode.
80
81
 
81
82
  ### Defaults and bounds
82
83
 
@@ -107,7 +108,7 @@ Inputs allow unambiguous `br:/branch@repository@server`, including literal host
107
108
  |---|---|
108
109
  | Preflight | Zero CLI; validates supplied local inputs and renders command intent, not remote conflicts |
109
110
  | Analyzed | Complete supported point-in-time remote output; no applying command or contributor export |
110
- | Completed | Complete normal exit emitted exactly one matching branch/repository/root-mount changeset |
111
+ | Completed | Admitted complete exit emitted one exact target/root changeset; conflict candidates require verified result inputs |
111
112
  | No-op / ALREADY_CONNECTED | Classification with effect not-proven; not independently verified unchanged state |
112
113
  | Conflict / unsupported / uncertain / failed | Stop; possible effects are not ruled out |
113
114
 
@@ -117,11 +118,11 @@ 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. 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
+ 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/RM/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. Applying has separate terminal/input 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
- 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.
124
- 5. **Observe:** inspect the receipt and any emitted target changeset. Never automatically retry a conflict, timeout or uncertain applying result. Keep source/target heads and all input files unchanged between inspection, validation and submission; these calls do not provide a transaction, head lock, result-content hash or atomic input snapshot. If the inputs or heads change, reassess instead of reusing stale merged content. The CLI owns current-conflict matching; its documented invalid-resolution rejection happens before upload, but opaque failures do not establish rollback.
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. The resultFile-only candidate-success route additionally hashes bounded canonical regular inputs before help, before dispatch and after completion; see [input verification](2026-10-21-observed-workflow-profiles.md#reviewed-result-file-server-merges).
125
+ 5. **Observe:** inspect the receipt and any emitted target changeset. Never automatically retry a conflict, timeout or uncertain applying result. Keep source/target heads and all input files unchanged between inspection, validation and submission; these calls provide no transaction, head lock or atomic input snapshot; result-input hashes are not general server-byte proof. If the inputs or heads change, reassess instead of reusing stale merged content. The CLI owns current-conflict matching; its documented invalid-resolution rejection happens before upload, but opaque failures do not establish rollback.
125
126
 
126
127
  ```text
127
128
  plastic_mergeBranches(source="br:/main/source@Example Repository@example@unity", target="br:/main@Example Repository@example@unity", mode="analyze")
@@ -138,7 +139,7 @@ plastic_mergeBranches(source="br:/main/source@Example Repository@example@unity",
138
139
  }
139
140
  ```
140
141
 
141
- The paths above are POSIX examples; on Windows supply native absolute local input paths. The tool and `cm` never change/delete the caller's JSON or result files. The JSON takes precedence over an optional explicit `unlistedConflictPolicy="source"|"destination"`, which emits `--keepsource`/`--keepdestination` only for that intentional fallback. Omit it to keep the existing noninteractive behavior for unlisted conflicts; never select a fallback automatically. `--nointeractiveresolution` is always present, including analysis. The resolution flag is required in local help only when a file is supplied, so ordinary merges retain their existing capability requirements. Local CM 11.0.16.10468 help advertises this flag; deterministic tests cover contracts, not live server-side resolution effects. Shelve sources/results are not added by this feature.
142
+ The paths above are POSIX examples; on Windows supply native absolute local input paths. The tool and `cm` never change/delete the caller's JSON or result files. The JSON takes precedence over an optional explicit `unlistedConflictPolicy="source"|"destination"`, which emits `--keepsource`/`--keepdestination` only for that intentional fallback. Omit it to keep the existing noninteractive behavior for unlisted conflicts; never select a fallback automatically. `--nointeractiveresolution` is always present, including analysis. The resolution flag is required in local help only when a file is supplied, so ordinary merges retain their existing capability requirements. Bounded Windows live and deterministic evidence is described in [observed profiles](2026-10-21-observed-workflow-profiles.md). Shelve sources/results are not added by this feature.
142
143
 
143
144
  ### Server merge bounds
144
145
 
@@ -48,7 +48,7 @@ A successful partial read is not a complete clean workspace. Native errors conta
48
48
 
49
49
  XML yields `data.mode="xml"`, absolute CLI-observed paths, `pathBasis="absolute"`, and workspace-wide scope even when workdir is a subdirectory. Moved `Path` is the destination and `OldPath` is the source.
50
50
 
51
- - Supported codes: CH, AD, DE, LD, MV, PR; types: enTextFile/enDirectory.
51
+ - Supported codes: CH, AD, CO, CP, RP, DE, LD, MV, LM, PR; types: enTextFile/enBinaryFile/enDirectory. LM observations retain source/destination and optional fractional similarity; see [inferred moves](2026-10-21-observed-workflow-profiles.md#inferred-local-moves).
52
52
  - Unsupported codes/types, duplicates, foreign/unknown important structures, malformed XML, warnings/errors, nonempty stderr, or command/capture failure reject the observation.
53
53
  - `baseRevisionAvailability="unavailable"`: changeset, size, and similarity never become per-item revisions.
54
54
  - Exact Unicode code points and emitted path spelling remain unchanged, including observed drive-letter case; no normalization/encoding guessing.
@@ -84,6 +84,7 @@ Machine yields `data.mode="machine"`, requestedShort, parsed-record summary, par
84
84
  - Header repository/server identities, cwd, internal normalized paths, and raw output are not canonical DTO fields.
85
85
  - Recognized blank/header records are not pending items.
86
86
  - The CLI no-base revision `-1` is omitted, not reusable. Malformed tokens such as `41.5` withhold revision identity and make the read incomplete; a numeric prefix is never recovered.
87
+ - Captured seven-field LM observations retain distinct source/destination paths and fractional similarity, including non-100% moves. This does not expand mutation/selection admission or authorize inferred renames.
87
88
  - Malformed/unsupported records or ambiguous legacy moves can produce a successful partial read, not complete status.
88
89
  - Successful stderr makes read/capture unknown without publishing stderr.
89
90
 
@@ -123,7 +124,7 @@ Read completeness is unknown. Both existing status commands must succeed; there
123
124
 
124
125
  ## Failures and follow-up
125
126
 
126
- Public codes include `command_failed`, `aborted`, `capture_incomplete`, `invalid_producer_data`, and `output_overflow`. XML schemaVersion 2 additionally emits `unsupported_source` for structurally valid but unadmitted record codes/types (including binary files), and `malformed_output` for invalid XML or observation-contract data. Parser bounds use `output_overflow`; transport truncation/timeouts use `capture_incomplete`. Unsupported records are not admitted or partially projected; malformed/unsafe data takes precedence over unsupported profiles. Errors expose only fixed messages/codes, not XML, record values or workdir paths, even with `includeRaw=true`.
127
+ Public codes include `command_failed`, `aborted`, `capture_incomplete`, `invalid_producer_data`, and `output_overflow`. XML schemaVersion 2 additionally emits `unsupported_source` for structurally valid but unadmitted record codes/types, and `malformed_output` for invalid XML or observation-contract data. Parser bounds use `output_overflow`; transport truncation/timeouts use `capture_incomplete`. Unsupported records are not admitted or partially projected; malformed/unsafe data takes precedence over unsupported profiles. Errors expose only fixed messages/codes, not XML, record values or workdir paths, even with `includeRaw=true`.
127
128
 
128
129
  Parser rejections retain `read="incomplete"`, `capture="complete"`, `projection=false`: bytes were captured, not accepted as a complete read. Command/capture failures retain unknown capture. `invalid_producer_data` remains reserved for unexpected producer/internal DTO validation failures. This is an additive extension of the v2 error-code union (enum consumers must handle the new codes); v1 and successful v2 shapes are unchanged, and existing v2 error envelopes remain valid. Generic historical failures without retained XML do not establish a particular unsupported type. Inspect host-native rejection as well as DTO `ok`.
129
130
 
@@ -59,9 +59,9 @@ Even complete exit 0 cannot establish restored bytes, preservation or zero pendi
59
59
  plastic_checkin(paths=["Assets/Example.txt"], message="Implement feature", includeAll=false, includePrivate=false)
60
60
  ```
61
61
 
62
- Inspect the declared selection defaults before omitting paths or enabling broad/private scope. `preflight=true` reads pending scope once and does not mutate; it is not a zero-command plan. `updateAfter` remains blocked before dispatch.
62
+ Inspect the declared selection defaults before omitting paths or enabling broad/private scope. `preflight=true` reads pending scope and any required merge-readiness observation without mutation; it is not a zero-command plan. `updateAfter` remains blocked before dispatch.
63
63
 
64
- The admitted Windows CM 11 source profile covers STATUS/pending and nonce-framed CI_START/STAGE/CO/AD/DE/MV/CHANGESET records, plus the narrow copied-path CP extension. Original Unicode, decimal-string changeset IDs, explicit branch prefixes and complete emitted server tails are preserved.
64
+ The admitted Windows CM 11 source profile covers STATUS/pending and nonce-framed CI_START/STAGE/CO/AD/DE/MV/CP/RP/CHANGESET records. [Merged selection and readiness](2026-10-21-observed-workflow-profiles.md#multi-file-workspace-merges-and-checkins) additionally correlate resolved source metadata, disjoint private exclusions and structural parent-directory events; partial merged selection/private inclusion remain unsupported. Original Unicode, decimal-string changeset IDs, explicit branch prefixes and complete emitted server tails are preserved.
65
65
 
66
66
  Only one admitted root changeset on successful complete capture supports `changeset-created`. Empty output/exit 0 alone does not. Unknown/malformed/full-tail records, incomplete capture and invalid UTF-8 fail before projection.
67
67
 
@@ -0,0 +1,54 @@
1
+ # Observed workflow profiles and limits
2
+
3
+ [Documentation index](README.md) · [Merging](2026-10-06-merging.md) · [Status](2026-10-06-status.md) · [Diffs and patches](2026-10-06-diffs-and-patches.md)
4
+
5
+ These bounded profiles were independently observed on Windows English CM 11.0.16.10484. They are not a promise of client-family, localization, Xlink, directory-conflict or binary-conflict support. Existing sourceAdmission labels identify historical source contracts, not the client or package revision currently loaded.
6
+
7
+ ## Multi-file workspace merges and checkins
8
+
9
+ The resolved route admits repeated and mixed rows structurally rather than requiring one exact global status tuple:
10
+
11
+ | Applying declaration/action | Pending row | Required resolved full-status row |
12
+ |---|---|---|
13
+ | FILE_SRC / DO_COPIED | RP | Replaced with the same merge source |
14
+ | FILE_CONFLICT / DO_MERGE | CO | Checked-out (changed) with the same merge source |
15
+ | APPLY ADD / DO_COPIED | CP | Copied (new) with the same merge source |
16
+ | APPLY RM / DO_DELETED | DE | Removed with the same merge source |
17
+
18
+ Framing, decimal tokens, byte/record bounds and unique canonical paths remain strict. Each declaration/action must correlate with one controlled pending path and the requested source link, repository/server and loaded namespace. Added directories/children and deleted file/directory rows use their captured layouts; deleting a directory need not enumerate every removed descendant. Unknown or contradictory hierarchies remain unsupported. Size and age displays are bounded opaque cells, including bytes/KB/MB, blank directory sizes, and Deleted sections without an age column. They are not revision or resolution evidence.
19
+
20
+ **FILE_CONFLICT + DO_MERGE and exit zero do not prove resolution.** Independent unresolved experiments emitted those same records and a pending source link, without explicit in-progress text. Their CO/NO_MERGES rows and unchanged full-status rows stop before checkin. Failed/unknown observations never authorize replay or automatic finalization.
21
+
22
+ Standalone checkin now performs an additional merge-readiness read when required. Ordinary CO also needs native link-state observation because the unresolved control uses CO/-1/NO_MERGES. A preflight can therefore perform more than one read. Ordinary checkout state is recognized through supported section/row markers, not words such as “conflict” embedded in a branch or filename; unknown sections/annotations still stop. Native checkin events must cover the merged controlled rows; extra CO events are admitted only for strict in-workspace structural ancestor directories, not arbitrary extra files. A single exact root branch/repository/server changeset and complete success capture are still required.
23
+
24
+ ### Private and partial selection
25
+
26
+ For the expanded merged route, `includePrivate=false` explicitly excludes observed unrelated PR rows. They must be lexically disjoint from all merged controlled paths; directory ancestors/descendants and collisions fail closed. Exact correlated controlled operands are used when private work is present, never workspace-wide private addition. Extra unrelated controlled rows still stop. Partial merged selection and `includePrivate=true` remain unsupported for this route. Existing ordinary non-merged recovery rules are separate; this merged route never adds private files or retries a checkin.
27
+
28
+ The compound route independently rechecks readiness and loaded target before checkin. It never replays an applying merge, compensates with undo, or claims rollback. Observations are not an exclusive transaction or a source/head/Xlink guarantee.
29
+
30
+ ## Reviewed result-file server merges
31
+
32
+ Analysis admits captured APPLY ADD/RM/FSPROT and supported discard-warning observations; applying admits captured ADD/RM with its stricter terminal-success rules. Analysis remains a read-only remote observation, not merge completion or approval to submit stale resolutions.
33
+
34
+ A FILE_CONFLICT candidate can become completed only with an admitted successful final root target changeset and exact stable reviewed result-file coverage. The new candidate-success route requires resultFile-only entries, no keep or unlisted-discard policy, and unique canonical conflict paths matching the candidate set. Existing keep-policy behavior outside this route is not broadened.
35
+
36
+ The caller's JSON is bounded to 1 MiB/500 entries; aggregate result bytes are bounded to 4 MiB. Inputs must be regular nonsymlink files at canonical absolute paths. Hash/identity observations are taken before help, revalidated before dispatch, and checked after completion. Missing, changed, extra or mismatched inputs leave completion unverified; observed emitted changesets remain recorded and must not be retried automatically. Inputs are never modified by the tool.
37
+
38
+ `parse.resolvedConflictsVerified` counts candidates with matching stable caller inputs, not general proof of server-stored result bytes. `parse.unknownOperationClasses` is a bounded closed set of sanitized classes; `unknownOperations` counts those classes, not arbitrary raw opcode strings. Exact emitted branch/repository/server spelling and root mount must match; alias equivalence remains unverified. Server comments admit bounded interior CR/LF only; other controls and malformed Unicode remain rejected.
39
+
40
+ These are point-in-time snapshots, not locks or atomic server-input guarantees. Keep all inputs and contributor heads immutable until completion. Independent validation loaded one synthetic typed result and checked exact bytes; that is evidence for the observed profile, not a verification automatically performed for every server merge.
41
+
42
+ ## Inferred local moves
43
+
44
+ Status XML and machine observations admit the captured LM layout with separate authoritative old/source and new/destination paths. Optional `similarity` is a fraction in [0,1]. XML may expose more precision than the machine percentage; rounded display values are not asserted equal or used as identity. Empty/sentinel display timestamps do not become valid revision timestamps.
45
+
46
+ Unsafe, duplicate, colliding, foreign-root or malformed move endpoints remain rejected/incomplete. XML rejects an invalid whole document; machine can return partial data only with truthful completeness. This observation support does not authorize a rename, change the user's add/delete intent, expand checkin selection, or rewrite diff policy.
47
+
48
+ ## Bounded diagnostics
49
+
50
+ Patch setup errors distinguish missing backend configuration, unverified selector repository and unavailable/existing output. On Windows configure `PI_PLASTIC_PATCH_EXECUTABLE` deliberately or pass a verified `toolPath`; the text-diff setting is not an automatic patch fallback.
51
+
52
+ Loaded-base diff errors distinguish fileinfo/ls profile, path/type, ownership and changeset/hash gates without echoing raw producer diagnostics. Large JSON presentations retain compact failure counts/reasons with indices referring only to returned structured outcomes. Historical files over the existing 4 MiB materialization cap are still rejected; `maxChars` controls output, not input bytes.
53
+
54
+ The exact supplemental fileinfo/ls profiles parse successfully while keeping loaded changeset, revision changeset and file revision ID distinct. They did not reproduce every historical asset diff failure. Improved diagnostics and regression evidence are not a claim that an unidentified historical cause has been fixed.
package/docs/README.md CHANGED
@@ -15,6 +15,7 @@ This is the public manual shipped with the package. Start with a task, then read
15
15
  | Find branches, workspaces, shelvesets, or review IDs | [Discovery](2026-10-06-discovery.md) |
16
16
  | Add/undo/check in files, switch branches, create/delete a branch | [Workspace operations](2026-10-06-workspace-operations.md) |
17
17
  | Choose workspace merge versus server merge versus closeout | [Merging](2026-10-06-merging.md#choose-a-route) |
18
+ | Understand observed multi-file, reviewed-server and inferred-move profiles | [Observed workflow profiles](2026-10-21-observed-workflow-profiles.md) |
18
19
  | Review local/historical changes or generate a patch | [Diffs and patches](2026-10-06-diffs-and-patches.md) |
19
20
  | Save/apply/delete a shelf or manage a review | [Shelvesets and reviews](2026-10-06-shelvesets-and-reviews.md) |
20
21
  | Understand display, footer, or ignore/cloak behavior | [Integrations](2026-10-06-integrations.md) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-plastic",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
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-inferred-move-status.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-real-merged-protocol.test.ts && tsx tests/plastic-real-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-real-server-apply.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.2.0",
51
- "@earendil-works/pi-tui": "1.2.0",
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
- const match = /^(\/[^@]+)@[^@]+@[^\s]+ \(cs:(?:0|[1-9][0-9]{0,19}) - head\)$/u.exec(lines[0]!);
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 };
@@ -5,8 +6,8 @@ import type { CheckinAttempt, CheckinCapture } from "../execution/checkin-comman
5
6
  export type { CheckinAttempt, CheckinCapture } from "../execution/checkin-command";
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
- 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 };
9
+ export type CheckinStep = { name: "pending-before" | "initial-checkin" | "private-add" | "private-retry" | "pending-recovery" | "fallback-checkin" | "pending-after" | "merge-diagnostic" | "merge-readiness"; 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 };
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,12 @@ 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 candidate = parseMergedPending(text, cwd);
52
+ const merged = candidate?.rows.some(r => r.status !== "CP") ? candidate : null;
53
+ if (merged) {
54
+ const items = parseMachineReadablePendingItems(text, cwd).map(item => ({ ...item, kind: item.statusCode === "CP" ? "added" as const : item.kind }));
55
+ return { admitted: items.length === merged.rows.length + merged.privateRows.length, records: merged.rows.length + merged.privateRows.length + 1, items, repository: merged.repository, server: merged.server, merged };
56
+ }
50
57
  const copied = parseCopiedPending(text, cwd);
51
58
  if (copied) {
52
59
  const items = parseMachineReadablePendingItems(text, cwd).map(item => ({ ...item, kind: "added" as const }));
@@ -34,7 +34,7 @@ export function admitCopiedStandardStatus(text: string, pending: CopiedPending,
34
34
  if (!link || link[1] !== pending.mergeChangeset || !linked || 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;
35
35
  const remaining = new Set(pending.paths.map(p => win32.relative(cwd, p)));
36
36
  for (const row of lines.slice(5)) {
37
- const candidates = [...remaining].filter(path => new RegExp(`^ Copied \\(new\\) \\(Merge from ${pending.mergeChangeset}\\) {2,}[0-9]+ bytes {2,}[^\\r\\n\\x00-\\x1f\\x7f]{1,128}? {2,}${escape(path)} *$`).test(row));
37
+ const candidates = [...remaining].filter(path => new RegExp(`^ Copied \\(new\\) \\(Merge from ${pending.mergeChangeset}\\) {2,}[0-9]{1,20}(?:\\.[0-9]{1,8})? (?:bytes|KB|MB) {2,}[^\\r\\n\\x00-\\x1f\\x7f]{1,128}? {2,}${escape(path)} *$`).test(row));
38
38
  if (candidates.length !== 1) return false;
39
39
  remaining.delete(candidates[0]);
40
40
  }
@@ -197,6 +197,8 @@ export const summarizeMergeOutput = (output: string): MergeOutputSummary =>
197
197
  };
198
198
  };
199
199
 
200
+ export const isSafeServerMergePath = (path: string): boolean => path.length > 1 && path.length <= 4096 && path.startsWith("/") && !path.includes(String.fromCharCode(92)) && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(path) && path.split("/").slice(1).every(part => !!part && part !== "." && part !== ".." && !/[. ]$/.test(part));
201
+
200
202
  export type ServerMergeRecord = {
201
203
  operation: string;
202
204
  fields: string[];
@@ -216,6 +218,9 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
216
218
  {
217
219
  const records: ServerMergeRecord[] = [];
218
220
  const unknownOperations = new Set<string>();
221
+ if (Buffer.byteLength(output, "utf8") > 65536 || Object.values(separators).some(s => !s || s.length > 128) || new Set(Object.values(separators)).size !== 3) {
222
+ return { records, malformed: true, unknownOperations: [], changesets: [], isAlreadyConnected: false, hasConflict: false, warningsObserved: 0 };
223
+ }
219
224
  let malformed = false;
220
225
  let cursor = 0;
221
226
 
@@ -224,14 +229,14 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
224
229
  const start = output.indexOf(separators.start, cursor);
225
230
  if (start < 0)
226
231
  {
227
- if (output.slice(cursor).trim() !== "")
232
+ if (!/^[\r\n]*$/.test(output.slice(cursor)))
228
233
  {
229
234
  malformed = true;
230
235
  }
231
236
  break;
232
237
  }
233
238
  const prefix = output.slice(cursor, start);
234
- if (prefix.trim() !== "")
239
+ if (!/^[\r\n]*$/.test(prefix))
235
240
  {
236
241
  malformed = true;
237
242
  }
@@ -261,7 +266,7 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
261
266
  records.push({ operation, fields });
262
267
  if (!["FILE_SRC", "CHANGESET", "STATUS", "FILE_CONFLICT", "APPLY", "DIS_OP_WARN"].includes(operation))
263
268
  {
264
- unknownOperations.add(operation);
269
+ unknownOperations.add("unsupported-operation");
265
270
  }
266
271
  }
267
272
  }
@@ -269,6 +274,16 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
269
274
  }
270
275
 
271
276
  const changesets: ServerChangeset[] = [];
277
+ const paths = new Set<string>();
278
+ for (let i = 0; i < records.length; i++) {
279
+ const r = records[i]!;
280
+ if (r.operation === "CHANGESET" && i !== records.length - 1 || r.operation === "STATUS" && records.length !== 1) malformed = true;
281
+ const path = r.operation === "APPLY" || r.operation === "DIS_OP_WARN" ? r.fields[2] : ["FILE_SRC", "FILE_CONFLICT"].includes(r.operation) ? r.fields[1] : undefined;
282
+ if (path !== undefined) {
283
+ if (!isSafeServerMergePath(path) || paths.has(path.toLowerCase())) malformed = true;
284
+ paths.add(path.toLowerCase());
285
+ }
286
+ }
272
287
  const decimal = (s: string) => /^(0|[1-9][0-9]{0,19})$/.test(s);
273
288
  const safe = (s: string) => s.length <= 4096 && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
274
289
  let isAlreadyConnected = false;
@@ -287,18 +302,18 @@ export const parseServerMergeOutput = (output: string, separators: { start: stri
287
302
  case "APPLY":
288
303
  case "DIS_OP_WARN":
289
304
  {
290
- // Only captured analysis profiles are supported. CHG is an observed
305
+ // APPLY profiles are mode-specific. CHG is an observed analysis
291
306
  // discard warning, not proof of identical contributor content.
292
307
  const subtype = record.fields[1]!;
293
- const supported = record.operation === "APPLY" ? ["ADD", "FSPROT"].includes(subtype) : subtype === "CHG";
308
+ const supported = record.operation === "APPLY" ? (mode === "apply" ? ["ADD", "RM"] : ["ADD", "RM", "FSPROT"]).includes(subtype) : mode === "analyze" && subtype === "CHG";
294
309
  const validFields = record.fields.length === 3 && record.fields[2]!.startsWith("/") && safe(record.fields[2]!);
295
310
  if (!validFields)
296
311
  {
297
312
  malformed = true;
298
313
  }
299
- if (!supported || mode !== "analyze")
314
+ if (!supported)
300
315
  {
301
- unknownOperations.add(record.operation + "/" + subtype);
316
+ unknownOperations.add(record.operation + "/unsupported-subtype");
302
317
  }
303
318
  else if (record.operation === "DIS_OP_WARN" && validFields)
304
319
  {
@@ -0,0 +1,121 @@
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" | "DE" };
12
+ export type MergedPending = CopiedPending & { rows: Array<{ path: string; status: "CP" | "RP" | "CO" | "DE"; isDirectory: boolean }>; privateRows: Array<{ path: string; isDirectory: boolean }> };
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" | "delete" }> = [];
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|DO_DELETED)$/.test(f[0]) && absolute(f[1])) actions.push({ path: f[1], status: f[0] === "DO_MERGE" ? "merge" : f[0] === "DO_DELETED" ? "delete" : "copy" });
25
+ else if (!actions.length && f.length === 3 && f[0] === "APPLY" && /^(ADD|RM)$/.test(f[1]) && relative(f[2])) declarations.push({ path: f[2], status: f[1] === "ADD" ? "CP" : "DE" });
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
+ // Absolute workspace scope is established by the pending observation below.
31
+ const paired = new Map<string, MergedDeclaration>(), remaining = new Set(actions);
32
+ // Pair the most specific repository paths first: /a.txt must not consume
33
+ // /dir/a.txt's action. Exact cwd-relative equality is still required below
34
+ // by correlateMergedApply before any readiness can be admitted.
35
+ for (const d of [...declarations].sort((a, b) => b.path.length - a.path.length)) {
36
+ const matches = [...remaining].filter(a => a.path.toLowerCase().endsWith(d.path.replaceAll("/", "\\").toLowerCase()) && a.status === (d.status === "CO" ? "merge" : d.status === "DE" ? "delete" : "copy"));
37
+ if (matches.length !== 1) return null;
38
+ remaining.delete(matches[0]); paired.set(d.path, { ...d, actionPath: matches[0].path });
39
+ }
40
+ return remaining.size === 0 ? declarations.map(d => paired.get(d.path)!) : 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"] = [], privateRows: MergedPending["privateRows"] = []; let mergeChangeset = "";
48
+ const keys = new Set<string>();
49
+ for (const line of lines.slice(1)) {
50
+ const f = line.split("\x1f"), m = /^Merge from ([1-9][0-9]{0,19})$/.exec(f[4] ?? "");
51
+ if (f.length !== 5 || !absolute(f[1]) || !/^(True|False)$/.test(f[2])) return null;
52
+ const rel = win32.relative(cwd, f[1]), key = copiedPathKey(f[1]);
53
+ if (!rel || rel.startsWith("..") || win32.isAbsolute(rel) || keys.has(key)) return null;
54
+ keys.add(key);
55
+ if (f[0] === "PR" && f[3] === "-1" && f[4] === "NO_MERGES") {
56
+ privateRows.push({ path: f[1], isDirectory: f[2] === "True" }); continue;
57
+ }
58
+ if (!/^(CO|RP|CP|DE)$/.test(f[0]) || f[2] === "True" && !/^(CP|DE)$/.test(f[0]) || !(f[0] === "CO" ? f[3] === "-1" : positive.test(f[3])) || !m || mergeChangeset && mergeChangeset !== m[1]) return null;
59
+ mergeChangeset = m[1]; rows.push({ path: f[1], status: f[0] as MergedDeclaration["status"], isDirectory: f[2] === "True" });
60
+ }
61
+ if (!rows.length || privateRows.some(parent => !parent.isDirectory && privateRows.some(child => copiedPathKey(child.path).startsWith(copiedPathKey(parent.path) + "\\")))) return null;
62
+ // A pending file cannot own descendants. The captured added-directory
63
+ // profile contains only copied children; deletion needs no child inventory.
64
+ if (rows.some(parent => rows.some(child => copiedPathKey(child.path).startsWith(copiedPathKey(parent.path) + "\\") && (!parent.isDirectory || parent.status !== "CP" || child.status !== "CP")))) return null;
65
+ // Exclusion is only sourced for unrelated private work, never descendants of
66
+ // a selected directory or ancestors/collisions of controlled merge paths.
67
+ if (privateRows.some(p => rows.some(r => {
68
+ const a = copiedPathKey(p.path), b = copiedPathKey(r.path);
69
+ return a === b || b.startsWith(a + "\\") || a.startsWith(b + "\\");
70
+ }))) return null;
71
+ return { changeset: h[1], repository: h[2], server: h[3], mergeChangeset, paths: rows.map(r => r.path), rows, privateRows };
72
+ }
73
+ export function correlateMergedApply(declarations: MergedDeclaration[], pending: MergedPending, cwd: string): boolean {
74
+ return declarations.length === pending.rows.length && declarations.every(d => {
75
+ const key = copiedPathKey(win32.join(cwd, d.path));
76
+ return copiedPathKey(d.actionPath) === key && pending.rows.some(r => copiedPathKey(r.path) === key && r.status === d.status);
77
+ });
78
+ }
79
+ /** Native checkin may report structural parent-directory CO events in addition
80
+ * to the pending rows. Only strict in-workspace ancestors are admitted extras;
81
+ * arbitrary file events, duplicates and missing selected rows remain uncertain. */
82
+ export function correlateMergedCheckin(events: ReadonlyArray<{ operation: string; path: string }>, pending: MergedPending, cwd: string): boolean {
83
+ if (!events.every(e => absolute(e.path)) || new Set(events.map(e => copiedPathKey(e.path))).size !== events.length) return false;
84
+ if (!pending.rows.every(r => events.some(e => copiedPathKey(e.path) === copiedPathKey(r.path) && e.operation === r.status))) return false;
85
+ return events.every(e => {
86
+ const key = copiedPathKey(e.path), row = pending.rows.find(r => copiedPathKey(r.path) === key);
87
+ if (row) return e.operation === row.status;
88
+ const rel = win32.relative(cwd, e.path);
89
+ return e.operation === "CO" && !!rel && !rel.startsWith("..") && !win32.isAbsolute(rel) && pending.rows.some(r => copiedPathKey(r.path).startsWith(key + "\\"));
90
+ });
91
+ }
92
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
93
+ export function admitMergedStandardStatus(text: string, pending: MergedPending, source: string, cwd: string): boolean {
94
+ const lines = text.split(/\r?\n/).filter(l => l.trim() !== ""), branch = parseSwitchLoadedBranch(text), requested = parseSwitchTarget(source);
95
+ 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;
96
+ const link = /^ Merge from cs:([1-9][0-9]{0,19}) at (.+)$/.exec(lines[2] ?? ""), linked = link && parseSwitchTarget(link[2]);
97
+ 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;
98
+ if (Buffer.byteLength(text, "utf8") > 65536 || lines.length > 140) return false;
99
+ let index = 3;
100
+ for (const section of ["Changed", "Deleted", "Added"]) {
101
+ const rows = pending.rows.filter(r => (r.status === "CP" ? "Added" : r.status === "DE" ? "Deleted" : "Changed") === section);
102
+ const privateRows = section === "Added" ? pending.privateRows : [];
103
+ if (!rows.length && !privateRows.length) continue;
104
+ const header = section === "Deleted" ? /^ Status +Size +Path *$/ : /^ Status +Size +Last Modified +Path *$/;
105
+ if (lines[index++] !== section || !header.test(lines[index++] ?? "")) return false;
106
+ const expected = [...rows.map(r => ({ ...r, label: `${r.status === "CO" ? "Checked-out (changed)" : r.status === "RP" ? "Replaced" : r.status === "DE" ? "Removed" : "Copied (new)"} (Merge from ${pending.mergeChangeset})` })), ...privateRows.map(r => ({ ...r, label: "Private" }))];
107
+ const remaining = new Set(expected);
108
+ for (let n = 0; n < expected.length; n++) {
109
+ const row = lines[index++] ?? "";
110
+ const matches = [...remaining].filter(r => {
111
+ // Display cells are bounded and opaque, never resolution/identity
112
+ // evidence. Directories have no size; deleted rows have no age.
113
+ const size = r.isDirectory ? "" : "[0-9]{1,20}(?:\\.[0-9]{1,8})? (?:bytes|KB|MB)";
114
+ const age = section === "Deleted" ? "" : "[^ \\r\\n\\x00-\\x1f\\x7f-\\x9f](?:[^ \\r\\n\\x00-\\x1f\\x7f-\\x9f]| (?! )){0,127}? {2,}";
115
+ return new RegExp(`^ ${escape(r.label)} {2,}${size}${size ? " {2,}" : ""}${age}${escape(win32.relative(cwd, r.path))} *$`).test(row);
116
+ });
117
+ if (matches.length !== 1) return false; remaining.delete(matches[0]);
118
+ }
119
+ }
120
+ return index === lines.length;
121
+ }