in2-agent-server 0.20260821.185429

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +369 -0
  2. package/agents/plan-requirement.md +32 -0
  3. package/dist/campfire/configItem.js +201 -0
  4. package/dist/campfire/configItemDescription.js +379 -0
  5. package/dist/campfire/deriveFromBundle.js +1013 -0
  6. package/dist/cli.js +465 -0
  7. package/dist/core/betaFeedback.js +493 -0
  8. package/dist/core/chapterInstructions.js +473 -0
  9. package/dist/core/claudeAssets.js +61 -0
  10. package/dist/core/gate.js +201 -0
  11. package/dist/core/ids.js +9 -0
  12. package/dist/core/install.js +60 -0
  13. package/dist/core/operatorQa.js +462 -0
  14. package/dist/core/orgCallMeter.js +178 -0
  15. package/dist/core/parcels.js +1235 -0
  16. package/dist/core/paths.js +105 -0
  17. package/dist/core/phases.js +225 -0
  18. package/dist/core/planFile.js +251 -0
  19. package/dist/core/planFragments.js +119 -0
  20. package/dist/core/readsLog.js +398 -0
  21. package/dist/core/registration.js +24 -0
  22. package/dist/core/result.js +38 -0
  23. package/dist/core/resume.js +233 -0
  24. package/dist/core/revisionChain.js +351 -0
  25. package/dist/core/richText.js +61 -0
  26. package/dist/core/semanticHash.js +89 -0
  27. package/dist/core/sessionOrg.js +60 -0
  28. package/dist/core/sfQuery.js +19 -0
  29. package/dist/core/spawn.js +182 -0
  30. package/dist/core/supervisorContract.js +230 -0
  31. package/dist/core/telemetry.js +1297 -0
  32. package/dist/core/telemetryContext.js +154 -0
  33. package/dist/core/telemetrySpool.js +244 -0
  34. package/dist/core/transcriptUpload.js +516 -0
  35. package/dist/core/update.js +363 -0
  36. package/dist/core/viewer.js +117 -0
  37. package/dist/core/workspace.js +92 -0
  38. package/dist/core/writeGrants.js +93 -0
  39. package/dist/core/writebackManifest.js +92 -0
  40. package/dist/direct/applyLoop.js +2516 -0
  41. package/dist/direct/batchReport.js +510 -0
  42. package/dist/direct/comparatorNoise.js +75 -0
  43. package/dist/direct/containers.js +85 -0
  44. package/dist/direct/contamination.js +221 -0
  45. package/dist/direct/crud.js +56 -0
  46. package/dist/direct/destructive.js +168 -0
  47. package/dist/direct/knownTokens.js +94 -0
  48. package/dist/direct/lint.js +382 -0
  49. package/dist/direct/metadataSchema.generated.json +1 -0
  50. package/dist/direct/metadataSchema.js +179 -0
  51. package/dist/direct/operatorPage.js +349 -0
  52. package/dist/direct/orgRepo.js +1531 -0
  53. package/dist/direct/orgRepoSpawnCount.js +21 -0
  54. package/dist/direct/plannedScope.js +119 -0
  55. package/dist/direct/preImageTargets.js +63 -0
  56. package/dist/direct/refVerify.js +191 -0
  57. package/dist/direct/registry.js +129 -0
  58. package/dist/direct/removals.js +262 -0
  59. package/dist/direct/rollback.js +1513 -0
  60. package/dist/direct/rollbackRun.js +1360 -0
  61. package/dist/direct/sessionProjection.js +126 -0
  62. package/dist/direct/snapshotComparability.js +165 -0
  63. package/dist/direct/sourceTree.js +113 -0
  64. package/dist/direct/unstatedChanges.js +106 -0
  65. package/dist/direct/waveAssembly.js +556 -0
  66. package/dist/direct/waveCounts.js +19 -0
  67. package/dist/direct/waveDerivation.js +152 -0
  68. package/dist/direct/waveGrade.js +185 -0
  69. package/dist/index.js +90 -0
  70. package/dist/orgread/customFieldSnapshot.js +48 -0
  71. package/dist/orgread/fieldMetadata.js +178 -0
  72. package/dist/orgread/genericSnapshot.js +61 -0
  73. package/dist/orgread/layoutSnapshot.js +151 -0
  74. package/dist/orgread/salesforceOrgReader.js +184 -0
  75. package/dist/orgread/snapshotTypes.js +9 -0
  76. package/dist/projection/bridge.js +26 -0
  77. package/dist/projection/diff.js +141 -0
  78. package/dist/projection/mapmd.js +213 -0
  79. package/dist/projection/nameAmbiguity.js +106 -0
  80. package/dist/projection/precedent.js +136 -0
  81. package/dist/projection/project.js +214 -0
  82. package/dist/projection/scope.js +110 -0
  83. package/dist/projection/types.js +5 -0
  84. package/dist/tools/analyze.js +322 -0
  85. package/dist/tools/auth.js +915 -0
  86. package/dist/tools/campfire.js +1742 -0
  87. package/dist/tools/closeBatch.js +154 -0
  88. package/dist/tools/closeText.js +35 -0
  89. package/dist/tools/direct.js +1613 -0
  90. package/dist/tools/feedback.js +318 -0
  91. package/dist/tools/operator.js +287 -0
  92. package/dist/tools/org.js +739 -0
  93. package/dist/tools/phase.js +249 -0
  94. package/dist/tools/projection.js +172 -0
  95. package/dist/tools/session.js +313 -0
  96. package/dist/tools/update.js +82 -0
  97. package/dist/tools/waves.js +793 -0
  98. package/package.json +48 -0
  99. package/reference/campfire-configuration-items-guide.md +710 -0
  100. package/reference/deployability-cribs.md +119 -0
  101. package/reference/operator-voice.md +141 -0
  102. package/reference/org-read-cribs.md +34 -0
  103. package/reference/scoped-dml-interim.md +68 -0
  104. package/reference/supervisor-playbook.md +313 -0
  105. package/reference/templates.md +233 -0
  106. package/skills/in2-delivery/SKILL.md +322 -0
  107. package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
  108. package/skills/in2-delivery/references/deployability-cribs.md +119 -0
  109. package/skills/in2-delivery/references/operator-voice.md +141 -0
  110. package/skills/in2-delivery/references/org-read-cribs.md +34 -0
  111. package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
  112. package/skills/in2-delivery/references/templates.md +233 -0
  113. package/skills/plan-requirement/SKILL.md +188 -0
  114. package/viewer/index.html +899 -0
  115. package/viewer/server.mjs +433 -0
@@ -0,0 +1,126 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { harvestProvenInto, knownTokensFrom, readProjectionManifest } from "./knownTokens.js";
4
+ const emptyView = () => ({ components: new Map(), objects: new Set(), sources: [] });
5
+ /** Fold one projection into the view. Later calls win — the caller orders by freshness. */
6
+ function mergeInto(view, source) {
7
+ const dir = path.resolve(source.dir);
8
+ view.sources.push(dir);
9
+ for (const obj of source.manifest.objects ?? [])
10
+ view.objects.add(obj.objectApiName);
11
+ for (const c of source.manifest.components ?? []) {
12
+ view.components.set(`${c.typeName}:${c.fullName}`, {
13
+ typeName: c.typeName,
14
+ fullName: c.fullName,
15
+ found: c.found,
16
+ // A row with no snapshot file still carries its EXISTENCE, which is the load-bearing half.
17
+ // Losing the snapshot costs precedent for one component; throwing here would cost the caller
18
+ // its whole lint, and grounding must never fail closed on a malformed row.
19
+ snapshotPath: typeof c.file === "string" && c.file ? path.resolve(dir, c.file) : "",
20
+ sourceDir: dir
21
+ });
22
+ }
23
+ }
24
+ /** Build a view from already-loaded manifests, applied in the order given (last wins). */
25
+ export function projectionView(sources) {
26
+ const view = emptyView();
27
+ for (const s of sources)
28
+ mergeInto(view, s);
29
+ return view;
30
+ }
31
+ /**
32
+ * The projection directories a session holds: `project_org` runs for `orgAlias`, plus every wave
33
+ * bundle's post-image.
34
+ *
35
+ * `orgAlias` scopes the project_org side and MUST be passed when it is known. A session can project
36
+ * MORE than its target org — Campfire reads project the consultant org — and a component existing
37
+ * over there is not evidence about the deploy target. The wave posts need no such scoping: a bundle
38
+ * only ever deploys to the session's target org.
39
+ */
40
+ async function sessionProjectionDirs(sessionRoot, orgAlias) {
41
+ const dirs = [];
42
+ if (orgAlias) {
43
+ const projRoot = path.join(sessionRoot, "org-projection", orgAlias);
44
+ try {
45
+ for (const e of await fs.readdir(projRoot, { withFileTypes: true })) {
46
+ if (e.isDirectory())
47
+ dirs.push(path.join(projRoot, e.name));
48
+ }
49
+ }
50
+ catch {
51
+ /* no project_org run for this org yet */
52
+ }
53
+ }
54
+ try {
55
+ const directRoot = path.join(sessionRoot, "direct");
56
+ for (const e of await fs.readdir(directRoot, { withFileTypes: true })) {
57
+ if (e.isDirectory())
58
+ dirs.push(path.join(directRoot, e.name, "post"));
59
+ }
60
+ }
61
+ catch {
62
+ /* first wave of the session — nothing has been delivered yet */
63
+ }
64
+ return dirs;
65
+ }
66
+ /**
67
+ * The session's view of the target org.
68
+ *
69
+ * `extra` sources are the CALLER'S OWN read — `direct_apply`'s pre-projection, taken seconds ago —
70
+ * and are applied last, after everything on disk, regardless of timestamps. That is deliberate:
71
+ * they are the freshest observation that exists, and ordering them by a manifest field would make
72
+ * grounding depend on clock skew between a projection and the tool that just ran one.
73
+ *
74
+ * Everything else is ordered by the manifest's `createdAt`, tie-broken on the directory path so the
75
+ * result never depends on readdir order — the same defect that let dual-claimed Configuration Items
76
+ * be settled silently by filesystem order.
77
+ */
78
+ export async function collectSessionProjection(sessionRoot, orgAlias, extra = []) {
79
+ const loaded = [];
80
+ for (const dir of await sessionProjectionDirs(sessionRoot, orgAlias)) {
81
+ // Best-effort per directory: a half-written bundle must not take out the whole view and turn a
82
+ // narrower-than-ideal warning into no warning at all.
83
+ try {
84
+ const manifest = await readProjectionManifest(dir);
85
+ loaded.push({ dir, manifest, createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : "" });
86
+ }
87
+ catch {
88
+ /* not a readable projection (wave never got past the deploy, or a partial write) */
89
+ }
90
+ }
91
+ loaded.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.dir.localeCompare(b.dir));
92
+ return projectionView([...loaded, ...extra]);
93
+ }
94
+ /**
95
+ * The view's sources as short labels — `ground`, `w2/post` — for a result line that says what was
96
+ * grounded against without pasting a screenful of absolute paths into every lint.
97
+ */
98
+ export function describeSources(view, max = 8) {
99
+ const labels = view.sources.map((dir) => path.basename(dir) === "post" ? `${path.basename(path.dirname(dir))}/post` : path.basename(dir));
100
+ return labels.length > max ? `${labels.slice(0, max).join(", ")}, +${labels.length - max} more` : labels.join(", ");
101
+ }
102
+ /** Every custom API token this view vouches for — the referential tier's ground truth. */
103
+ export function viewKnownTokens(view) {
104
+ return knownTokensFrom([...view.components.values()], [...view.objects]);
105
+ }
106
+ /**
107
+ * Values for org-enforced elements (`CustomTab.motif`, `FlexiPage.componentName`), proven by
108
+ * components that exist in this org. Only the freshest snapshot of each component is read, so a
109
+ * component seen by three projections costs one file read rather than three.
110
+ */
111
+ export async function viewProvenValues(view) {
112
+ const out = new Map();
113
+ for (const c of view.components.values()) {
114
+ if (!c.found || !c.snapshotPath)
115
+ continue;
116
+ try {
117
+ const snap = JSON.parse(await fs.readFile(c.snapshotPath, "utf8"));
118
+ if (snap.state.metadata)
119
+ harvestProvenInto(snap.state.metadata, out, c.typeName);
120
+ }
121
+ catch {
122
+ /* unreadable snapshot contributes nothing */
123
+ }
124
+ }
125
+ return out;
126
+ }
@@ -0,0 +1,165 @@
1
+ // IS THIS PAIR OF SNAPSHOTS EVIDENCE ABOUT THE ORG, OR ABOUT THE READER? (live-caught, PV Arm B)
2
+ //
3
+ // `MATERIALIZER_VERSION` versions the WRITE side — how bundles become commits. Nothing versioned the
4
+ // READ side, and the mirror compares a snapshot captured months ago against one captured today. When
5
+ // the reader changes in between, the two describe the same org state in two different shapes, and
6
+ // `computeDrift`'s byte comparison reports the difference as CHANGED-OUTSIDE-HISTORY — a claim that
7
+ // someone edited the component. R15's whole discipline is that a drift card is a claim about
8
+ // somebody's work; a claim produced by our own refactor is a false accusation.
9
+ //
10
+ // LIVE-CAUGHT 2026-08-02, PV corpus Arm B, session ses_20df7fb1b75daf736c8f1cd7 wave w3, org
11
+ // 00Dfn00000BFjbBEAT: `Drift: Layout:PV_Asset__c-PV Asset Layout`. The mirror's copy was captured by
12
+ // the pre-#21 Layout reader; w3's pre-image by the new one. The complete delta, diffed from the two
13
+ // artifacts on disk:
14
+ //
15
+ // family "generic" -> "layout"
16
+ // source "metadata.read" -> "metadata.read + tooling:Layout.Metadata.relatedLists"
17
+ // relatedLists[*] gained quickActions: [], sortField: null, sortOrder: null
18
+ //
19
+ // Zero org-state bytes. Every `layoutSection`, every `layoutItem`, the related-list SET itself —
20
+ // byte-identical. The card was 100% reader shape. 13 of the 14 Layouts mirrored in that org are
21
+ // still recorded under the old reader and would each produce the same false card on their next read.
22
+ //
23
+ // WHY NOT NORMALIZE THE DIFFERENCE AWAY (the comparatorNoise.ts approach)
24
+ //
25
+ // Because the old value is not a differently-shaped truth, it is NOT EVIDENCE. #21 exists because
26
+ // SOAP `readMetadata` silently omits Layout related lists — measured that day across all 436 layouts
27
+ // in the reference org: 12 disagreed with the deploy-symmetric source, 56 related-list items missing,
28
+ // and NOT all-or-nothing (17 of 20, 23 of 34, 18 of 26 on three separate layouts). Declaring the old
29
+ // `relatedLists` equal to the new one would therefore assert something false on 12 layouts in 436 —
30
+ // and on exactly those, the assertion would silently swallow a REAL related-list deletion. The
31
+ // noise budget's own bar is "a MEASURED org behaviour, not a plausible one"; here the measurement
32
+ // says the two values are not the same fact, so normalization is the wrong instrument.
33
+ //
34
+ // The right answer is the honest one: say the two reads are not comparable, and say what differed.
35
+ //
36
+ // WHAT THIS IS NOT
37
+ //
38
+ // Not suppression. A component gated here is NEVER reported as clean — it is reported as its own
39
+ // finding (`IncomparableSnapshot`), carrying both reader identities and the state keys that actually
40
+ // differ, so a real third-party edit hiding behind the reader change is visible to the supervisor
41
+ // rather than deleted from the record. And the blindness is one wave wide: the wave commits its
42
+ // post-image under the CURRENT reader, so the next comparison of that component is fully comparable.
43
+ //
44
+ // A SECOND GATE, on what a snapshot admits it does not contain. `readLayoutSnapshot` DROPS the
45
+ // facet it could not verify and declares `INCOMPLETE_FACET relatedLists: ...`; orgrepo-v4 persists
46
+ // that declaration into the committed snapshot as `incompleteFacets`, so it survives on the
47
+ // recorded side too. Both sides are consulted (`FACET_UNREAD`) — a facet EITHER read never held is
48
+ // not evidence about the org, and comparing against it would card a probe outage as a related-list
49
+ // deletion, or a probe RECOVERY as someone adding related lists.
50
+ //
51
+ // The gate key is `(family, source)` — READ OFF THE STORED SNAPSHOT rather than stamped as a new
52
+ // version field. That is deliberate and it is what makes this work at all: all 272 blobs already in
53
+ // the live mirror carry both fields, so the gate applies to history already on disk, with no rebuild
54
+ // and no "an unstamped blob means old" amnesty rule. A version field added today would be absent
55
+ // from every snapshot the problem is actually about. Same reasoning as `harnessVersion()` — a
56
+ // measurement of what the reader did beats a number someone has to remember to bump.
57
+ import { incompleteFacetsOf } from "../orgread/layoutSnapshot.js";
58
+ export const readerIdentityLabel = (id) => `${id.family}|${id.source}`;
59
+ /**
60
+ * The facets a snapshot DECLARES it does not contain, read off the snapshot itself.
61
+ *
62
+ * `canonicalSnapshot` persists this as `incompleteFacets` (orgrepo-v4), so both sides of a
63
+ * comparison can speak. Absent on every pre-v4 blob, which is correct: an old snapshot makes no
64
+ * declaration, and `[]` is exactly "declares nothing" rather than "declares completeness".
65
+ */
66
+ function declaredIncompleteFacets(parsed) {
67
+ const value = parsed?.incompleteFacets;
68
+ // A malformed marker is not a declaration. Trusting one would silence a real difference, and
69
+ // doubt here fails towards the card.
70
+ if (!Array.isArray(value))
71
+ return [];
72
+ return value.filter((f) => typeof f === "string");
73
+ }
74
+ function readerIdentityOf(parsed) {
75
+ const s = parsed;
76
+ if (!s || typeof s.family !== "string" || typeof s.source !== "string")
77
+ return null;
78
+ return { family: s.family, source: s.source };
79
+ }
80
+ /**
81
+ * The metadata keys whose values differ — the evidence a reader of the finding needs.
82
+ *
83
+ * Top level of `state.metadata` where a snapshot has one (every metadata-API read does), the top of
84
+ * `state` otherwise. Deliberately shallow: this is not a diff, it is the answer to "which facets are
85
+ * involved", which is what tells a supervisor whether the reader change explains the whole thing.
86
+ */
87
+ function differingKeys(a, b) {
88
+ const facetsOf = (parsed) => {
89
+ const state = parsed?.state;
90
+ if (!state || typeof state !== "object" || Array.isArray(state))
91
+ return {};
92
+ const metadata = state.metadata;
93
+ const target = metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : state;
94
+ return target;
95
+ };
96
+ const left = facetsOf(a);
97
+ const right = facetsOf(b);
98
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
99
+ const out = [];
100
+ for (const key of keys) {
101
+ // Both sides are already canonicalized (keys sorted deep), so a string compare of the serialized
102
+ // values is an exact value compare and needs no deep-equality helper.
103
+ if (JSON.stringify(left[key]) !== JSON.stringify(right[key]))
104
+ out.push(key);
105
+ }
106
+ return out.sort();
107
+ }
108
+ /**
109
+ * Are these two snapshots of one component comparable as claims about the ORG?
110
+ *
111
+ * Only ever asked of a pair that already differs — an equal pair raises no question. Every failure
112
+ * path returns COMPARABLE, because "drift stands" is the direction a doubt here must fail in: a
113
+ * missed gate costs a false card that a supervisor classifies, while a gate that fires on garbage
114
+ * would silence a real one.
115
+ *
116
+ * BOTH SIDES DECLARE. `canonicalSnapshot` persists an INCOMPLETE_FACET declaration as
117
+ * `incompleteFacets` (orgrepo-v4), so the recorded snapshot says what it does not contain and the
118
+ * gate is symmetric. It closes the asymmetry this comment used to bound: previously only the fresh
119
+ * side could speak, and the case that reached past the identity gate — a reader declaring a facet
120
+ * unread WITHOUT varying its `source` — had no answer at all. Now a facet EITHER side never read is
121
+ * withheld, which is the only honest reading: a difference in a facet one side never held is not
122
+ * evidence about the org in either direction.
123
+ *
124
+ * `observedErrors` is still taken separately, and it is not redundant: the fresh pre-image is
125
+ * compared BEFORE it is committed, so on the live path its declaration exists only in `errors`. The
126
+ * two sources are unioned rather than ranked — a marker anywhere in the pair is a gap in the pair.
127
+ *
128
+ * Pre-v4 blobs carry no `incompleteFacets`, and that reads as "declares nothing" rather than
129
+ * "declares completeness" — the same direction every other unknown here fails in.
130
+ */
131
+ export function assessComparability(args) {
132
+ let recorded;
133
+ let observed;
134
+ try {
135
+ recorded = JSON.parse(args.recorded);
136
+ observed = JSON.parse(args.observed);
137
+ }
138
+ catch {
139
+ return { kind: "COMPARABLE" };
140
+ }
141
+ const recordedId = readerIdentityOf(recorded);
142
+ const observedId = readerIdentityOf(observed);
143
+ if (!recordedId || !observedId)
144
+ return { kind: "COMPARABLE" };
145
+ const keys = differingKeys(recorded, observed);
146
+ if (recordedId.family !== observedId.family || recordedId.source !== observedId.source) {
147
+ return { kind: "READER_CHANGED", recorded: recordedId, observed: observedId, differingKeys: keys };
148
+ }
149
+ // Same reader, same surfaces — but either side may still have declared a facet unreadable. A facet
150
+ // one of them says it never read is not evidence about that facet, in either direction, and it
151
+ // does not matter which side is the one that could not read it.
152
+ const facets = [
153
+ ...new Set([
154
+ ...incompleteFacetsOf(args.observedErrors),
155
+ ...declaredIncompleteFacets(observed),
156
+ ...declaredIncompleteFacets(recorded)
157
+ ])
158
+ ];
159
+ if (facets.length > 0 && keys.length > 0 && keys.every((k) => facets.includes(k))) {
160
+ return { kind: "FACET_UNREAD", facets: facets.filter((f) => keys.includes(f)).sort(), differingKeys: keys };
161
+ }
162
+ // Differences outside the unread facets are real differences. The card stands on those, and the
163
+ // unread facet does not get to shelter them.
164
+ return { kind: "COMPARABLE" };
165
+ }
@@ -0,0 +1,113 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { childByDirectory, lookupType, typesByDirectory } from "./registry.js";
4
+ // Map a source-format RELATIVE path (package-root-relative, e.g. "objects/X__c/fields/F__c.field-meta.xml"
5
+ // or "flows/My.flow-meta.xml" or "email/unfiled$public/T.email") back to its component identity.
6
+ // Registry-driven so it covers any type the forward resolver writes. Returns null for a path the
7
+ // walker does not recognize (surfaced as `skipped`, never silently dropped).
8
+ export function resolveComponentFromRelPath(relPath) {
9
+ const segs = relPath.split(/[\\/]/).filter(Boolean);
10
+ const [top, ...rest] = segs;
11
+ if (!top || rest.length === 0)
12
+ return null;
13
+ // objects/OBJ/… — the CustomObject folderPerType decomposition (object + nested children).
14
+ if (top === "objects") {
15
+ const [obj, childDir, fileName] = rest;
16
+ if (!obj)
17
+ return null;
18
+ if (rest.length === 2 && childDir === `${obj}.object-meta.xml`) {
19
+ return { typeName: "CustomObject", fullName: obj, role: "meta" };
20
+ }
21
+ if (rest.length === 3 && childDir && fileName) {
22
+ const co = lookupType("CustomObject");
23
+ if (!co)
24
+ return null;
25
+ const child = childByDirectory(co, childDir);
26
+ if (!child?.suffix)
27
+ return null;
28
+ const tail = `.${child.suffix}-meta.xml`;
29
+ if (!fileName.endsWith(tail))
30
+ return null;
31
+ return { typeName: child.name, fullName: `${obj}.${fileName.slice(0, -tail.length)}`, role: "meta" };
32
+ }
33
+ return null;
34
+ }
35
+ // Top-level type by its directory name. A directory can host multiple types, so pick the
36
+ // candidate whose SUFFIX fits the file — meta files first (their `.suffix-meta.xml` is more
37
+ // specific than a bare `.suffix`), then content files. `remainder` may carry a folder segment
38
+ // for folder types (email/<Folder>/<Name>.email-meta.xml → fullName "<Folder>/<Name>").
39
+ const candidates = typesByDirectory(top).filter((c) => Boolean(c.suffix));
40
+ const remainder = rest.join("/");
41
+ for (const entry of candidates) {
42
+ const metaTail = `.${entry.suffix}-meta.xml`;
43
+ if (remainder.endsWith(metaTail))
44
+ return { typeName: entry.name, fullName: remainder.slice(0, -metaTail.length), role: "meta" };
45
+ }
46
+ for (const entry of candidates) {
47
+ const contentTail = `.${entry.suffix}`;
48
+ if (remainder.endsWith(contentTail))
49
+ return { typeName: entry.name, fullName: remainder.slice(0, -contentTail.length), role: "content" };
50
+ }
51
+ return null;
52
+ }
53
+ // Path relative to the package root: tolerate either a bare tree (objects/… directly under
54
+ // sourceDir) or a full SFDX layout (force-app/main/default/objects/…) by stripping a
55
+ // `main/default/` prefix when present.
56
+ function relFromPackageRoot(abs, rootDir) {
57
+ const rel = path.relative(rootDir, abs).split(path.sep).join("/");
58
+ const marker = rel.indexOf("main/default/");
59
+ return marker >= 0 ? rel.slice(marker + "main/default/".length) : rel;
60
+ }
61
+ async function listFilesRecursive(dir) {
62
+ const out = [];
63
+ const walk = async (d) => {
64
+ const entries = await fs.readdir(d, { withFileTypes: true });
65
+ for (const e of entries) {
66
+ const abs = path.join(d, e.name);
67
+ if (e.isDirectory())
68
+ await walk(abs);
69
+ else if (e.isFile())
70
+ out.push(abs);
71
+ }
72
+ };
73
+ await walk(dir);
74
+ return out;
75
+ }
76
+ // Walk a source-format tree into ComponentEdit[]. Content-file types (EmailTemplate, …) pair
77
+ // their `-meta.xml` (→ `xml`) with the sibling content file (→ `content`). Unrecognized files
78
+ // and content files with no meta are reported in `skipped` — fail-loud, never silently dropped.
79
+ export async function walkSourceTree(rootDir) {
80
+ const abs = path.resolve(rootDir);
81
+ const files = await listFilesRecursive(abs);
82
+ const byKey = new Map();
83
+ const skipped = [];
84
+ for (const fileAbs of files.sort()) {
85
+ const rel = relFromPackageRoot(fileAbs, abs);
86
+ const resolved = resolveComponentFromRelPath(rel);
87
+ if (!resolved) {
88
+ skipped.push({ file: rel, reason: "unrecognized source path (not a known type/child layout)" });
89
+ continue;
90
+ }
91
+ const key = `${resolved.typeName}:${resolved.fullName}`;
92
+ const group = byKey.get(key) ?? { typeName: resolved.typeName, fullName: resolved.fullName };
93
+ if (resolved.role === "meta")
94
+ group.metaAbs = fileAbs;
95
+ else
96
+ group.contentAbs = fileAbs;
97
+ byKey.set(key, group);
98
+ }
99
+ const components = [];
100
+ for (const g of [...byKey.values()].sort((a, b) => `${a.typeName}:${a.fullName}`.localeCompare(`${b.typeName}:${b.fullName}`))) {
101
+ if (!g.metaAbs) {
102
+ skipped.push({ file: `${g.typeName}:${g.fullName}`, reason: "content file without a -meta.xml sibling" });
103
+ continue;
104
+ }
105
+ const edit = { typeName: g.typeName, fullName: g.fullName, xml: await fs.readFile(g.metaAbs, "utf8") };
106
+ if (g.contentAbs) {
107
+ edit.content = await fs.readFile(g.contentAbs, "utf8");
108
+ edit.contentEncoding = "utf8";
109
+ }
110
+ components.push(edit);
111
+ }
112
+ return { components, skipped };
113
+ }
@@ -0,0 +1,106 @@
1
+ import { canonJson, diffMetadata, isChildCollectionPath } from "../projection/diff.js";
2
+ import { PLATFORM_DEFAULT_WHEN_OMITTED, canonicalizeCurrencyish, coerceLeaf, isEmptyish, isPlainObj } from "./comparatorNoise.js";
3
+ /**
4
+ * Both sides through the same shape normalizations, so only CONTENT differences survive.
5
+ *
6
+ * `null` collapses to `undefined` deliberately: diffMetadata renders the latter as `(absent)`, and a
7
+ * facet that went absent belongs to findRemovals — one rendering keeps that filter honest instead of
8
+ * having it match two spellings of the same thing.
9
+ */
10
+ function canonicalize(v) {
11
+ if (v === null || v === undefined)
12
+ return undefined;
13
+ if (Array.isArray(v)) {
14
+ const items = v.map(canonicalize);
15
+ // The Metadata API collapses a one-element collection to a bare object on read-back, and which
16
+ // side of a deploy gets which shape is not something the author controls. compareIntent
17
+ // normalizes the same pair; here it must be done on both sides at once.
18
+ const shaped = items.length === 1 ? items[0] : items;
19
+ return isEmptyish(shaped) ? "" : shaped;
20
+ }
21
+ if (isPlainObj(v)) {
22
+ const rec = coerceLeaf(v);
23
+ if (!isPlainObj(rec))
24
+ return canonicalize(rec); // {_, $} text-node collapsed to its scalar
25
+ const out = {};
26
+ for (const [k, x] of Object.entries(rec))
27
+ out[k] = canonicalize(x);
28
+ return isEmptyish(out) ? "" : out;
29
+ }
30
+ return coerceLeaf(canonicalizeCurrencyish(v));
31
+ }
32
+ /**
33
+ * Did the org MATERIALIZE this key, rather than the deploy changing it?
34
+ *
35
+ * Only asked where the pre-image had nothing at all, so this can never suppress a value that moved —
36
+ * it decides whether a newly-present facet carries content or is the platform writing down a default
37
+ * it had previously left implicit.
38
+ */
39
+ function isMaterialized(key, value, container) {
40
+ // "Absent means false" — the org drops a boolean equal to the platform default and can materialize
41
+ // it back on a later read. compareIntent treats exactly this pair as agreement.
42
+ if (value === "false" || value === false)
43
+ return true;
44
+ // A skeleton is not content: `controllingFieldValues: []` appearing is the org describing its own
45
+ // data model, not a change to the component (a class named in the run's noise catalogue).
46
+ if (isEmptyish(value))
47
+ return true;
48
+ if (key in PLATFORM_DEFAULT_WHEN_OMITTED && canonJson(PLATFORM_DEFAULT_WHEN_OMITTED[key]) === canonJson(value)) {
49
+ return true;
50
+ }
51
+ // Value-set entries: the org stamps `label` = `fullName` onto every customValue on read-back
52
+ // (stress-caught case E, 2026-07-22), the same materialization normalizeIntent mirrors.
53
+ if (key === "label" && typeof value === "string" && container.fullName === value)
54
+ return true;
55
+ return false;
56
+ }
57
+ /** Drop from the post-image every key the pre-image never had AND the org merely materialized. */
58
+ function withoutMaterialized(before, after) {
59
+ if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) {
60
+ return after.map((el, i) => withoutMaterialized(before[i], el));
61
+ }
62
+ if (!isPlainObj(before) || !isPlainObj(after))
63
+ return after;
64
+ const out = {};
65
+ for (const [k, v] of Object.entries(after)) {
66
+ if (before[k] === undefined) {
67
+ if (!isMaterialized(k, v, after))
68
+ out[k] = v;
69
+ continue;
70
+ }
71
+ out[k] = withoutMaterialized(before[k], v);
72
+ }
73
+ return out;
74
+ }
75
+ /** Root facet of a diff path — `metadata.foo[bar=x].baz` → `foo`. */
76
+ function rootOf(path) {
77
+ return path.split(".")[1]?.replace(/\[.*$/, "") ?? "";
78
+ }
79
+ export function findUnstatedChanges(typeName, pre, post, intent) {
80
+ if (!pre || !post)
81
+ return []; // a create has no pre-image — nothing can have changed
82
+ const statedRoots = new Set(Object.keys(intent ?? {}));
83
+ const before = canonicalize(pre);
84
+ const after = withoutMaterialized(before, canonicalize(post));
85
+ return diffMetadata(before, after)
86
+ .filter((c) => !c.orderOnly)
87
+ .filter((c) => !statedRoots.has(rootOf(c.path)))
88
+ // A disappearance is findRemovals' finding — it blocks the same verdict and says the one thing
89
+ // that matters about it (content was LOST, not changed). Two alarms for one event cost more
90
+ // attention than they buy.
91
+ .filter((c) => c.after !== "(absent)")
92
+ // The CustomObject read-back EMBEDS the child collections (fields, listViews, validationRules,
93
+ // …) that the projection also captures as their own components. Without this, a wave that
94
+ // deploys an object plus its fields reports every field as an unstated change on the parent.
95
+ // diffProjections excludes them from the object diff for the same reason.
96
+ .filter((c) => !(typeName === "CustomObject" && isChildCollectionPath(c.path)))
97
+ .map(({ path, before: b, after: a }) => ({ path, before: b, after: a }));
98
+ }
99
+ export function renderUnstatedChanges(component, changes) {
100
+ // Named individually, with BOTH values: a count sends the reader to the bundle to find out which
101
+ // setting moved and to what, which is the manual diff-reading this check exists to retire. Past a
102
+ // handful it inverts and would bury every other finding, so it collapses.
103
+ const shown = changes.slice(0, 8).map((c) => `${c.path} (${c.before} → ${c.after})`);
104
+ const more = changes.length > shown.length ? ` …and ${changes.length - shown.length} more` : "";
105
+ return `${component}: ${shown.join(", ")}${more}`;
106
+ }