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.
- package/README.md +369 -0
- package/agents/plan-requirement.md +32 -0
- package/dist/campfire/configItem.js +201 -0
- package/dist/campfire/configItemDescription.js +379 -0
- package/dist/campfire/deriveFromBundle.js +1013 -0
- package/dist/cli.js +465 -0
- package/dist/core/betaFeedback.js +493 -0
- package/dist/core/chapterInstructions.js +473 -0
- package/dist/core/claudeAssets.js +61 -0
- package/dist/core/gate.js +201 -0
- package/dist/core/ids.js +9 -0
- package/dist/core/install.js +60 -0
- package/dist/core/operatorQa.js +462 -0
- package/dist/core/orgCallMeter.js +178 -0
- package/dist/core/parcels.js +1235 -0
- package/dist/core/paths.js +105 -0
- package/dist/core/phases.js +225 -0
- package/dist/core/planFile.js +251 -0
- package/dist/core/planFragments.js +119 -0
- package/dist/core/readsLog.js +398 -0
- package/dist/core/registration.js +24 -0
- package/dist/core/result.js +38 -0
- package/dist/core/resume.js +233 -0
- package/dist/core/revisionChain.js +351 -0
- package/dist/core/richText.js +61 -0
- package/dist/core/semanticHash.js +89 -0
- package/dist/core/sessionOrg.js +60 -0
- package/dist/core/sfQuery.js +19 -0
- package/dist/core/spawn.js +182 -0
- package/dist/core/supervisorContract.js +230 -0
- package/dist/core/telemetry.js +1297 -0
- package/dist/core/telemetryContext.js +154 -0
- package/dist/core/telemetrySpool.js +244 -0
- package/dist/core/transcriptUpload.js +516 -0
- package/dist/core/update.js +363 -0
- package/dist/core/viewer.js +117 -0
- package/dist/core/workspace.js +92 -0
- package/dist/core/writeGrants.js +93 -0
- package/dist/core/writebackManifest.js +92 -0
- package/dist/direct/applyLoop.js +2516 -0
- package/dist/direct/batchReport.js +510 -0
- package/dist/direct/comparatorNoise.js +75 -0
- package/dist/direct/containers.js +85 -0
- package/dist/direct/contamination.js +221 -0
- package/dist/direct/crud.js +56 -0
- package/dist/direct/destructive.js +168 -0
- package/dist/direct/knownTokens.js +94 -0
- package/dist/direct/lint.js +382 -0
- package/dist/direct/metadataSchema.generated.json +1 -0
- package/dist/direct/metadataSchema.js +179 -0
- package/dist/direct/operatorPage.js +349 -0
- package/dist/direct/orgRepo.js +1531 -0
- package/dist/direct/orgRepoSpawnCount.js +21 -0
- package/dist/direct/plannedScope.js +119 -0
- package/dist/direct/preImageTargets.js +63 -0
- package/dist/direct/refVerify.js +191 -0
- package/dist/direct/registry.js +129 -0
- package/dist/direct/removals.js +262 -0
- package/dist/direct/rollback.js +1513 -0
- package/dist/direct/rollbackRun.js +1360 -0
- package/dist/direct/sessionProjection.js +126 -0
- package/dist/direct/snapshotComparability.js +165 -0
- package/dist/direct/sourceTree.js +113 -0
- package/dist/direct/unstatedChanges.js +106 -0
- package/dist/direct/waveAssembly.js +556 -0
- package/dist/direct/waveCounts.js +19 -0
- package/dist/direct/waveDerivation.js +152 -0
- package/dist/direct/waveGrade.js +185 -0
- package/dist/index.js +90 -0
- package/dist/orgread/customFieldSnapshot.js +48 -0
- package/dist/orgread/fieldMetadata.js +178 -0
- package/dist/orgread/genericSnapshot.js +61 -0
- package/dist/orgread/layoutSnapshot.js +151 -0
- package/dist/orgread/salesforceOrgReader.js +184 -0
- package/dist/orgread/snapshotTypes.js +9 -0
- package/dist/projection/bridge.js +26 -0
- package/dist/projection/diff.js +141 -0
- package/dist/projection/mapmd.js +213 -0
- package/dist/projection/nameAmbiguity.js +106 -0
- package/dist/projection/precedent.js +136 -0
- package/dist/projection/project.js +214 -0
- package/dist/projection/scope.js +110 -0
- package/dist/projection/types.js +5 -0
- package/dist/tools/analyze.js +322 -0
- package/dist/tools/auth.js +915 -0
- package/dist/tools/campfire.js +1742 -0
- package/dist/tools/closeBatch.js +154 -0
- package/dist/tools/closeText.js +35 -0
- package/dist/tools/direct.js +1613 -0
- package/dist/tools/feedback.js +318 -0
- package/dist/tools/operator.js +287 -0
- package/dist/tools/org.js +739 -0
- package/dist/tools/phase.js +249 -0
- package/dist/tools/projection.js +172 -0
- package/dist/tools/session.js +313 -0
- package/dist/tools/update.js +82 -0
- package/dist/tools/waves.js +793 -0
- package/package.json +48 -0
- package/reference/campfire-configuration-items-guide.md +710 -0
- package/reference/deployability-cribs.md +119 -0
- package/reference/operator-voice.md +141 -0
- package/reference/org-read-cribs.md +34 -0
- package/reference/scoped-dml-interim.md +68 -0
- package/reference/supervisor-playbook.md +313 -0
- package/reference/templates.md +233 -0
- package/skills/in2-delivery/SKILL.md +322 -0
- package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
- package/skills/in2-delivery/references/deployability-cribs.md +119 -0
- package/skills/in2-delivery/references/operator-voice.md +141 -0
- package/skills/in2-delivery/references/org-read-cribs.md +34 -0
- package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
- package/skills/in2-delivery/references/templates.md +233 -0
- package/skills/plan-requirement/SKILL.md +188 -0
- package/viewer/index.html +899 -0
- 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
|
+
}
|