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,85 @@
|
|
|
1
|
+
import { parseMetadataXml } from "./lint.js";
|
|
2
|
+
// What a deployed FILE actually contains, as components.
|
|
3
|
+
//
|
|
4
|
+
// The subtlety that makes a naive 1:1 mapping WRONG: several metadata types deploy as a CONTAINER
|
|
5
|
+
// holding many independently-configurable children — a Workflow file carries N email alerts, a
|
|
6
|
+
// SharingRules file carries N rules, and a full-file CustomObject can carry its fields inline.
|
|
7
|
+
// Live example (6.5.4 wave 2): one deployed `Workflow:CF70_Case_Readiness__c` is ZERO Workflow
|
|
8
|
+
// components and ONE WorkflowAlert.
|
|
9
|
+
//
|
|
10
|
+
// TWO CONSUMERS, ONE MAP. Campfire derives Configuration Items from it (a CI is one configurable
|
|
11
|
+
// component, not one file, and CI accuracy is a paid metric). The org mirror derives what a wave
|
|
12
|
+
// TOUCHED from it — a child deployed inside its parent's file is a real wave-caused change that
|
|
13
|
+
// the request never names on its own. They must agree: a component that is a CI for one and
|
|
14
|
+
// invisible to the other would mean the delivery record and the mirror disagree about the same
|
|
15
|
+
// deploy.
|
|
16
|
+
/** Container child element -> the metadata type of the child. Child fullName is "<parent>.<child>". */
|
|
17
|
+
const CONTAINER_CHILDREN = {
|
|
18
|
+
Workflow: {
|
|
19
|
+
alerts: "WorkflowAlert",
|
|
20
|
+
fieldUpdates: "WorkflowFieldUpdate",
|
|
21
|
+
outboundMessages: "WorkflowOutboundMessage",
|
|
22
|
+
rules: "WorkflowRule",
|
|
23
|
+
tasks: "WorkflowTask"
|
|
24
|
+
},
|
|
25
|
+
SharingRules: {
|
|
26
|
+
sharingCriteriaRules: "SharingCriteriaRule",
|
|
27
|
+
sharingOwnerRules: "SharingOwnerRule"
|
|
28
|
+
},
|
|
29
|
+
MatchingRules: { matchingRules: "MatchingRule" },
|
|
30
|
+
AssignmentRules: { assignmentRule: "AssignmentRule" },
|
|
31
|
+
// A full-file CustomObject can carry children inline. The direct tier normally deploys these as
|
|
32
|
+
// their own components, so expansion is a safety net; duplicates are removed by key.
|
|
33
|
+
CustomObject: {
|
|
34
|
+
fields: "CustomField",
|
|
35
|
+
listViews: "ListView",
|
|
36
|
+
recordTypes: "RecordType",
|
|
37
|
+
compactLayouts: "CompactLayout",
|
|
38
|
+
validationRules: "ValidationRule",
|
|
39
|
+
webLinks: "WebLink",
|
|
40
|
+
fieldSets: "FieldSet"
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
// Expand one submitted component into the configurable components it actually contains.
|
|
44
|
+
// A container contributes only its children; a non-container contributes itself.
|
|
45
|
+
function expandBody(typeName, fullName, body) {
|
|
46
|
+
const childMap = CONTAINER_CHILDREN[typeName];
|
|
47
|
+
if (!childMap)
|
|
48
|
+
return [{ typeName, fullName, configuration: body }];
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const [element, childType] of Object.entries(childMap)) {
|
|
51
|
+
const raw = body[element];
|
|
52
|
+
if (raw === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
for (const entry of Array.isArray(raw) ? raw : [raw]) {
|
|
55
|
+
const childName = entry && typeof entry === "object" ? entry.fullName : undefined;
|
|
56
|
+
if (typeof childName !== "string" || !childName)
|
|
57
|
+
continue;
|
|
58
|
+
out.push({
|
|
59
|
+
typeName: childType,
|
|
60
|
+
fullName: `${fullName}.${childName}`,
|
|
61
|
+
expandedFrom: `${typeName}:${fullName}`,
|
|
62
|
+
configuration: entry
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// CustomObject is a real component in its own right; the pure containers (Workflow, SharingRules,
|
|
67
|
+
// …) are NOT — Campfire has no Type value for them, only for their children.
|
|
68
|
+
if (typeName === "CustomObject")
|
|
69
|
+
out.unshift({ typeName, fullName, configuration: body });
|
|
70
|
+
return out.length > 0 ? out : [{ typeName, fullName, configuration: body }];
|
|
71
|
+
}
|
|
72
|
+
export function expandComponent(typeName, fullName, xml) {
|
|
73
|
+
if (!xml)
|
|
74
|
+
return [{ typeName, fullName }];
|
|
75
|
+
const parsed = parseMetadataXml(xml);
|
|
76
|
+
if (!parsed)
|
|
77
|
+
return [{ typeName, fullName }];
|
|
78
|
+
return expandBody(typeName, fullName, parsed.body);
|
|
79
|
+
}
|
|
80
|
+
/** Expand a canonical org read-back through the same container map as submitted XML. */
|
|
81
|
+
export function expandObservedComponent(typeName, fullName, metadata) {
|
|
82
|
+
if (!metadata)
|
|
83
|
+
return [{ typeName, fullName }];
|
|
84
|
+
return expandBody(typeName, fullName, metadata);
|
|
85
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { deriveReadSet, readReadsLog, strongerPattern } from "../core/readsLog.js";
|
|
4
|
+
import { sessionDir } from "../core/workspace.js";
|
|
5
|
+
import { deliveredComponents } from "./orgRepo.js";
|
|
6
|
+
/** 15-char prefix compare: the last three characters of an 18-char id are a checksum, not identity. */
|
|
7
|
+
const sameOrg = (a, b) => a.slice(0, 15) === b.slice(0, 15);
|
|
8
|
+
async function readManifest(file) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Compute the report. Reads only — no org contact, no writes, nothing sent anywhere.
|
|
18
|
+
*/
|
|
19
|
+
export async function computeContamination(input) {
|
|
20
|
+
const records = await readReadsLog(input.sessionId);
|
|
21
|
+
// The session may have read more than one org (a `targetOrg` override names an org this session
|
|
22
|
+
// was never bound to). Only the bound org's reads can contaminate the bound org's measurement.
|
|
23
|
+
const sets = deriveReadSet(records).filter((set) => set.orgId
|
|
24
|
+
? sameOrg(set.orgId, input.orgId)
|
|
25
|
+
: Boolean(input.orgAlias) && set.orgAliases.includes(input.orgAlias));
|
|
26
|
+
const byKey = new Map();
|
|
27
|
+
const observe = (key, pattern, tools) => {
|
|
28
|
+
const existing = byKey.get(key);
|
|
29
|
+
if (!existing) {
|
|
30
|
+
byKey.set(key, { pattern, tools: [...new Set(tools)] });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
existing.pattern = strongerPattern(existing.pattern, pattern);
|
|
34
|
+
for (const tool of tools)
|
|
35
|
+
if (!existing.tools.includes(tool))
|
|
36
|
+
existing.tools.push(tool);
|
|
37
|
+
};
|
|
38
|
+
let calls = 0;
|
|
39
|
+
let opaqueReads = 0;
|
|
40
|
+
const opaqueByPattern = { enumeration: 0, targeted: 0, body: 0 };
|
|
41
|
+
let aliasJoinedCalls = 0;
|
|
42
|
+
const manifests = { pointers: 0, resolved: 0, components: 0, unreadable: [] };
|
|
43
|
+
for (const set of sets) {
|
|
44
|
+
calls += set.calls;
|
|
45
|
+
opaqueReads += set.opaqueReads;
|
|
46
|
+
for (const pattern of ["enumeration", "targeted", "body"]) {
|
|
47
|
+
opaqueByPattern[pattern] += set.opaqueByPattern[pattern];
|
|
48
|
+
}
|
|
49
|
+
if (!set.orgId)
|
|
50
|
+
aliasJoinedCalls += set.calls;
|
|
51
|
+
for (const component of set.components)
|
|
52
|
+
observe(component.key, component.pattern, component.tools);
|
|
53
|
+
// R10 — `project_org` and `direct_apply` record a POINTER to the manifest they already wrote
|
|
54
|
+
// rather than duplicating a component list that runs to hundreds of entries. Resolving it here
|
|
55
|
+
// is what makes that indirection free: a report that stopped at the inline `keys` would grade a
|
|
56
|
+
// full projection of a predecessor's object as no overlap at all.
|
|
57
|
+
for (const pointer of set.manifests) {
|
|
58
|
+
manifests.pointers += 1;
|
|
59
|
+
const manifest = await readManifest(pointer.path);
|
|
60
|
+
if (!manifest) {
|
|
61
|
+
manifests.unreadable.push(pointer.path);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
manifests.resolved += 1;
|
|
65
|
+
for (const entry of manifest.components ?? []) {
|
|
66
|
+
// `found: false` means the org did not have it. The run read nothing of it, and a component
|
|
67
|
+
// a prior arm delivered would have been found — so this contributes no evidence either way.
|
|
68
|
+
if (entry.found !== true)
|
|
69
|
+
continue;
|
|
70
|
+
if (typeof entry.typeName !== "string" || typeof entry.fullName !== "string")
|
|
71
|
+
continue;
|
|
72
|
+
manifests.components += 1;
|
|
73
|
+
observe(`${entry.typeName}:${entry.fullName}`, pointer.pattern, [pointer.tool]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// The other half of the join. A commit by THIS session is this run's own work, not a predecessor's
|
|
78
|
+
// corpus — a run cannot contaminate itself.
|
|
79
|
+
const history = await deliveredComponents({
|
|
80
|
+
orgId: input.orgId,
|
|
81
|
+
...(input.root ? { root: input.root } : {}),
|
|
82
|
+
...(input.limit ? { limit: input.limit } : {})
|
|
83
|
+
});
|
|
84
|
+
const prior = history.filter((entry) => entry.sessionId !== input.sessionId);
|
|
85
|
+
const deliveriesByKey = new Map();
|
|
86
|
+
for (const entry of prior) {
|
|
87
|
+
const list = deliveriesByKey.get(entry.key) ?? [];
|
|
88
|
+
list.push({
|
|
89
|
+
date: entry.date,
|
|
90
|
+
sessionId: entry.sessionId ?? null,
|
|
91
|
+
wave: entry.wave ?? null,
|
|
92
|
+
verdict: entry.verdict ?? null,
|
|
93
|
+
removed: entry.removed
|
|
94
|
+
});
|
|
95
|
+
deliveriesByKey.set(entry.key, list);
|
|
96
|
+
}
|
|
97
|
+
const overlap = [];
|
|
98
|
+
for (const [key, deliveries] of deliveriesByKey) {
|
|
99
|
+
const seen = byKey.get(key);
|
|
100
|
+
if (!seen)
|
|
101
|
+
continue;
|
|
102
|
+
overlap.push({ key, pattern: seen.pattern, tools: seen.tools, deliveries });
|
|
103
|
+
}
|
|
104
|
+
// Strongest first, so the line that matters is the line nobody has to scroll for.
|
|
105
|
+
const rank = { body: 0, targeted: 1, enumeration: 2 };
|
|
106
|
+
overlap.sort((a, b) => rank[a.pattern] - rank[b.pattern] || a.key.localeCompare(b.key));
|
|
107
|
+
const overlapCounts = { enumeration: 0, targeted: 0, body: 0 };
|
|
108
|
+
for (const entry of overlap)
|
|
109
|
+
overlapCounts[entry.pattern] += 1;
|
|
110
|
+
const priorSessions = [
|
|
111
|
+
...new Set(prior.map((entry) => entry.sessionId).filter((id) => Boolean(id)))
|
|
112
|
+
].sort();
|
|
113
|
+
const finding = deliveriesByKey.size === 0
|
|
114
|
+
? "NO_PRIOR_ARMS"
|
|
115
|
+
: overlap.length === 0
|
|
116
|
+
? "NO_OVERLAP"
|
|
117
|
+
: overlapCounts.body + overlapCounts.targeted > 0
|
|
118
|
+
? "NAMED"
|
|
119
|
+
: "ENUMERATION_ONLY";
|
|
120
|
+
return {
|
|
121
|
+
sessionId: input.sessionId,
|
|
122
|
+
orgId: input.orgId,
|
|
123
|
+
generatedAt: new Date().toISOString(),
|
|
124
|
+
priorSessions,
|
|
125
|
+
priorComponents: deliveriesByKey.size,
|
|
126
|
+
readSet: { components: byKey.size, calls, opaqueReads, opaqueByPattern, aliasJoinedCalls },
|
|
127
|
+
manifests,
|
|
128
|
+
overlap,
|
|
129
|
+
overlapCounts,
|
|
130
|
+
finding
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const describeDelivery = (d) => `${d.sessionId ?? "(unattributed session)"} ${d.wave ?? "(unattributed wave)"}` +
|
|
134
|
+
`${d.removed ? " [removed it]" : ""} — ${d.verdict ?? "?"}, ${d.date}`;
|
|
135
|
+
/**
|
|
136
|
+
* One graded section. Absent entirely when empty — a heading over nothing reads as a finding, and
|
|
137
|
+
* "READ IN FULL: none" is a sentence a skim turns into its opposite.
|
|
138
|
+
*/
|
|
139
|
+
function renderGroup(report, pattern, heading, gloss) {
|
|
140
|
+
const entries = report.overlap.filter((o) => o.pattern === pattern);
|
|
141
|
+
if (entries.length === 0)
|
|
142
|
+
return [];
|
|
143
|
+
const lines = [``, `${heading} — ${entries.length} component(s).`, ...gloss];
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
lines.push(` ${entry.key} (seen by ${entry.tools.join(", ")})`);
|
|
146
|
+
for (const delivery of entry.deliveries)
|
|
147
|
+
lines.push(` delivered by ${describeDelivery(delivery)}`);
|
|
148
|
+
}
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The operator-facing rendering. Every path through it states the opaque count, and none of them
|
|
153
|
+
* says "clean" — see the header note.
|
|
154
|
+
*/
|
|
155
|
+
export function renderContamination(report) {
|
|
156
|
+
const lines = [
|
|
157
|
+
`CONTAMINATION REPORT — session ${report.sessionId}, org ${report.orgId}`,
|
|
158
|
+
`Generated ${report.generatedAt}. Local evidence only; nothing here was sent anywhere.`,
|
|
159
|
+
``
|
|
160
|
+
];
|
|
161
|
+
if (report.finding === "NO_PRIOR_ARMS") {
|
|
162
|
+
lines.push(`No prior arms recorded on this machine for this org.`, `That is a statement about THIS INSTALL's history, not about the org: a wave run from another`, `machine, or before this install existed, is invisible here until its parcels sync.`);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
lines.push(`Prior arms in this org's recorded history: ${report.priorSessions.length || "unattributed"}` +
|
|
166
|
+
`${report.priorSessions.length ? ` (${report.priorSessions.join(", ")})` : ""}, ` +
|
|
167
|
+
`${report.priorComponents} component(s) delivered before this run.`);
|
|
168
|
+
}
|
|
169
|
+
lines.push(``, `This run's read-set: ${report.readSet.components} component identit${report.readSet.components === 1 ? "y" : "ies"} ` +
|
|
170
|
+
`over ${report.readSet.calls} org read(s)` +
|
|
171
|
+
`${report.manifests.resolved ? `, including ${report.manifests.components} component(s) from ${report.manifests.resolved} projection manifest(s)` : ""}.`, `Opaque reads: ${report.readSet.opaqueReads} — org read(s) that reached the org and derived no component identity.`, `Everything below is bounded by that number: the strongest claim available here is "nothing`, `derivable overlapped", which is not the same claim as "nothing overlapped".`);
|
|
172
|
+
// The graded blind spot. Stated ONLY when there is one: "of which 0 were full-definition reads" is
|
|
173
|
+
// a reassurance this report does not issue, and a heading over nothing reads as a finding.
|
|
174
|
+
const opaqueBody = report.readSet.opaqueByPattern.body;
|
|
175
|
+
if (opaqueBody > 0) {
|
|
176
|
+
lines.push(`Of those, ${opaqueBody} were full-definition (body) read(s) that could not be attributed to any component:`, `this run read ${opaqueBody} definition(s) in full and nothing here can say whose. Body is the only`, `pattern that can carry a prior arm's design across arms, so any body-grade result below —`, `INCLUDING a count of zero — is bounded by these ${opaqueBody} before it is read as reassuring.`);
|
|
177
|
+
}
|
|
178
|
+
if (report.readSet.aliasJoinedCalls > 0) {
|
|
179
|
+
lines.push(`${report.readSet.aliasJoinedCalls} read(s) recorded no org id and were joined by alias — an alias is` +
|
|
180
|
+
` operator-controlled and can be repointed, so those reads are attributed, not proven.`);
|
|
181
|
+
}
|
|
182
|
+
if (report.manifests.unreadable.length > 0) {
|
|
183
|
+
lines.push(`${report.manifests.unreadable.length} manifest(s) could not be read, so their components are absent from the read-set` +
|
|
184
|
+
` above: ${report.manifests.unreadable.join(", ")}`);
|
|
185
|
+
}
|
|
186
|
+
lines.push(...renderGroup(report, "body", `READ IN FULL (body)`, [
|
|
187
|
+
` This run read the definition of these predecessor-delivered components. This is the only`,
|
|
188
|
+
` pattern that can carry a prior arm's design into this one.`
|
|
189
|
+
]), ...renderGroup(report, "targeted", `NAMED (targeted)`, [
|
|
190
|
+
` This run asked for these predecessor-delivered components by name. The request named them;`,
|
|
191
|
+
` it did not necessarily return their definitions.`
|
|
192
|
+
]), ...renderGroup(report, "enumeration", `LISTED (enumeration)`, [
|
|
193
|
+
` These predecessor-delivered components appeared in a listing this run made.`,
|
|
194
|
+
` Listing a family is not reading a design (origin AE1): reported, not an accusation.`
|
|
195
|
+
]));
|
|
196
|
+
if (report.finding === "NO_OVERLAP") {
|
|
197
|
+
lines.push(``, `Nothing this run derived an identity for intersects what prior arms delivered here.`);
|
|
198
|
+
}
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
201
|
+
/** Where the report lands in the session's evidence. */
|
|
202
|
+
export function contaminationArtifacts(sessionId) {
|
|
203
|
+
const dir = sessionDir(sessionId);
|
|
204
|
+
return { json: path.join(dir, "contamination.json"), markdown: path.join(dir, "CONTAMINATION.md") };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Write the report beside the session's other evidence. FAIL-OPEN: this runs at batch end, after
|
|
208
|
+
* the work is done, so an unwritable session directory costs the artifact and nothing else.
|
|
209
|
+
*/
|
|
210
|
+
export async function writeContaminationArtifacts(report) {
|
|
211
|
+
try {
|
|
212
|
+
const files = contaminationArtifacts(report.sessionId);
|
|
213
|
+
await fs.mkdir(path.dirname(files.json), { recursive: true });
|
|
214
|
+
await fs.writeFile(files.json, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
215
|
+
await fs.writeFile(files.markdown, `${renderContamination(report)}\n`, "utf8");
|
|
216
|
+
return files;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { meteredApiCall } from "../core/orgCallMeter.js";
|
|
2
|
+
// CRUD Metadata API path for parent-object facet updates. Lazily imports
|
|
3
|
+
// @salesforce/core (same jsforce chain as the live reader — Node >= 24).
|
|
4
|
+
// Pattern: read the object's CURRENT top-level scalar state, overlay the facet
|
|
5
|
+
// patch, update, read back.
|
|
6
|
+
//
|
|
7
|
+
// CRUD update() REPLACES the component's own top-level properties — it does NOT
|
|
8
|
+
// field-level-merge scalars. So any top-level scalar left out of the payload reverts
|
|
9
|
+
// to its default. Live-caught (6.5.1 run, 2026-07-24): a `compactLayoutAssignment`
|
|
10
|
+
// facet write built from only 4 keys silently reset enableActivities/enableReports to
|
|
11
|
+
// false (Activities is load-bearing for the flow), needing a manual restore. The fix is
|
|
12
|
+
// to carry EVERY top-level scalar as-read, not a hand-maintained subset — closed-world
|
|
13
|
+
// key lists are exactly the whack-a-mole this tier is meant to retire. Child collections
|
|
14
|
+
// (fields, listViews, recordTypes, nameField, … — objects/arrays) are deliberately
|
|
15
|
+
// omitted: Salesforce manages those separately and merges them when absent, and resending
|
|
16
|
+
// them risks clobbering. nameField is the one compound we preserve (some updates need it) — and it
|
|
17
|
+
// is preserved AS-READ, every key, for the same reason the scalars are. Live-caught (EV-31…40 run,
|
|
18
|
+
// 2026-08-02): it used to be rebuilt as `{label, type}`, which on an AutoNumber object dropped
|
|
19
|
+
// `displayFormat` — the format string IS the naming behaviour there, so `A-AGR-{00000}` was wiped by
|
|
20
|
+
// an unrelated facet write. A closed-world key list one level down is the same whack-a-mole, and
|
|
21
|
+
// nameField's keys (displayFormat, startingNumber, trackHistory, trackFeedHistory, …) are exactly
|
|
22
|
+
// the kind that accumulate. Carry what the org gave us; only the caller's facets override.
|
|
23
|
+
export function buildObjectFacetPayload(read, objectApiName, facets) {
|
|
24
|
+
const payload = { fullName: objectApiName };
|
|
25
|
+
for (const [key, value] of Object.entries(read)) {
|
|
26
|
+
if (key === "fullName" || key === "nameField")
|
|
27
|
+
continue;
|
|
28
|
+
if (value === null || value === undefined)
|
|
29
|
+
continue;
|
|
30
|
+
if (typeof value === "object")
|
|
31
|
+
continue; // arrays + nested objects = child metadata, skip
|
|
32
|
+
payload[key] = value; // string | number | boolean scalar facet — preserve as-read
|
|
33
|
+
}
|
|
34
|
+
const nameField = read.nameField;
|
|
35
|
+
if (nameField && typeof nameField === "object" && !Array.isArray(nameField)) {
|
|
36
|
+
payload.nameField = { ...nameField };
|
|
37
|
+
}
|
|
38
|
+
return { ...payload, ...facets };
|
|
39
|
+
}
|
|
40
|
+
export async function updateObjectFacets(alias, apiVersion, objectApiName, facets) {
|
|
41
|
+
// Metered as org calls — connection setup, the read and the update are three round-trips, and a
|
|
42
|
+
// facet update is the one write path that does NOT go through the sf CLI. Leaving them uncounted
|
|
43
|
+
// would put a hole in the per-wave total exactly where a wave touches parent objects.
|
|
44
|
+
const { Org } = await import("@salesforce/core");
|
|
45
|
+
const org = await meteredApiCall(() => Org.create({ aliasOrUsername: alias }));
|
|
46
|
+
const conn = org.getConnection(apiVersion);
|
|
47
|
+
const read = await meteredApiCall(() => conn.metadata.read("CustomObject", objectApiName));
|
|
48
|
+
if (!read || typeof read !== "object" || read.fullName === undefined) {
|
|
49
|
+
throw new Error(`CustomObject ${objectApiName} not readable — refusing a blind facet update.`);
|
|
50
|
+
}
|
|
51
|
+
const payload = buildObjectFacetPayload(read, objectApiName, facets);
|
|
52
|
+
const result = await meteredApiCall(() => conn.metadata.update("CustomObject", payload));
|
|
53
|
+
const ok = Array.isArray(result) ? result[0]?.success : result?.success;
|
|
54
|
+
if (ok !== true)
|
|
55
|
+
throw new Error(`facet update refused: ${JSON.stringify(result)}`);
|
|
56
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Destructive deletes: the package, and what the org says back about each member.
|
|
2
|
+
//
|
|
3
|
+
// Deletes ride an mdapi destructive deploy (destructiveChanges.xml + an empty package.xml, submitted
|
|
4
|
+
// with `--metadata-dir`). That form accepts `--dry-run` exactly as a source deploy does — checkOnly
|
|
5
|
+
// is a deploy-API option, not a property of the source format — so a delete CAN be validated
|
|
6
|
+
// server-side before anything is destroyed. The loop asserted the opposite for as long as this path
|
|
7
|
+
// existed, and reported every delete in a dry run as NOT VALIDATED.
|
|
8
|
+
//
|
|
9
|
+
// Everything below is written against RESPONSES, not against the documentation. See
|
|
10
|
+
// destructive.test.ts for the verbatim T-Pinto payloads (2026-08-02) that each rule came from.
|
|
11
|
+
const XML_NS = 'xmlns="http://soap.sforce.com/2006/04/metadata"';
|
|
12
|
+
export function destructivePackageXml(deletes) {
|
|
13
|
+
const byType = new Map();
|
|
14
|
+
for (const d of deletes)
|
|
15
|
+
byType.set(d.typeName, [...(byType.get(d.typeName) ?? []), d.fullName]);
|
|
16
|
+
const types = [...byType.entries()]
|
|
17
|
+
.map(([t, names]) => ` <types>\n${names.map((n) => ` <members>${n}</members>`).join("\n")}\n <name>${t}</name>\n </types>`)
|
|
18
|
+
.join("\n");
|
|
19
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<Package ${XML_NS}>\n${types}\n</Package>\n`;
|
|
20
|
+
}
|
|
21
|
+
export function emptyPackageXml(apiVersion) {
|
|
22
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<Package ${XML_NS}>\n <version>${apiVersion}</version>\n</Package>\n`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Every problem the org reported, from BOTH channels it uses.
|
|
26
|
+
*
|
|
27
|
+
* `details.componentFailures` and the CLI's flattened `files[]` carry the same diagnostics under
|
|
28
|
+
* different key names (`problem`/`error`), and which one is populated has varied. Reading both and
|
|
29
|
+
* de-duplicating is cheaper than being wrong about which is authoritative.
|
|
30
|
+
*/
|
|
31
|
+
function problemsOf(deploy) {
|
|
32
|
+
const raw = (deploy.raw ?? {});
|
|
33
|
+
const rows = [...(deploy.failures ?? []), ...(Array.isArray(raw.files) ? raw.files : [])];
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
if (typeof row !== "object" || row === null)
|
|
38
|
+
continue;
|
|
39
|
+
const r = row;
|
|
40
|
+
const problem = typeof r.problem === "string" ? r.problem : typeof r.error === "string" ? r.error : "";
|
|
41
|
+
if (!problem)
|
|
42
|
+
continue; // a clean `files[]` row is an outcome, not a problem
|
|
43
|
+
const fullName = typeof r.fullName === "string" ? r.fullName : "";
|
|
44
|
+
const problemType = typeof r.problemType === "string" ? r.problemType : "Error";
|
|
45
|
+
const key = `${fullName}\u0000${problemType}\u0000${problem}`;
|
|
46
|
+
if (seen.has(key))
|
|
47
|
+
continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
out.push({ fullName, problemType, problem });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** The `files[]` rows that report an accomplished (or validated) state rather than a problem. */
|
|
54
|
+
function statesOf(deploy) {
|
|
55
|
+
const raw = (deploy.raw ?? {});
|
|
56
|
+
const out = new Map();
|
|
57
|
+
if (!Array.isArray(raw.files))
|
|
58
|
+
return out;
|
|
59
|
+
for (const row of raw.files) {
|
|
60
|
+
if (typeof row !== "object" || row === null)
|
|
61
|
+
continue;
|
|
62
|
+
const r = row;
|
|
63
|
+
if (typeof r.fullName !== "string" || typeof r.state !== "string")
|
|
64
|
+
continue;
|
|
65
|
+
if (r.error || r.problem)
|
|
66
|
+
continue;
|
|
67
|
+
out.set(r.fullName, r.state);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Does `text` name `member` — as the whole name, not as a prefix of a longer one?
|
|
73
|
+
*
|
|
74
|
+
* Needed because a missing member's diagnostic keys itself to the FILE ("destructiveChanges.xml"),
|
|
75
|
+
* so the only place the component's name appears is the prose. Matching on a bare `includes` would
|
|
76
|
+
* hand `Account.Foo__c`'s blocker to a wave that also deletes `Account.Foo__c_2`.
|
|
77
|
+
*/
|
|
78
|
+
function namesMember(text, member) {
|
|
79
|
+
let from = 0;
|
|
80
|
+
for (;;) {
|
|
81
|
+
const at = text.indexOf(member, from);
|
|
82
|
+
if (at < 0)
|
|
83
|
+
return false;
|
|
84
|
+
const after = text[at + member.length];
|
|
85
|
+
const before = text[at - 1];
|
|
86
|
+
const boundary = (c) => c === undefined || !/[A-Za-z0-9_.]/.test(c);
|
|
87
|
+
if (boundary(before) && boundary(after))
|
|
88
|
+
return true;
|
|
89
|
+
from = at + 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Map an org response for a destructive submission onto the members that were requested.
|
|
94
|
+
*
|
|
95
|
+
* `mode` only changes the WORDING: "dry-run" says what would happen, "real" says what did. The
|
|
96
|
+
* matching is identical, because the two responses are the same shape — measured, by running the
|
|
97
|
+
* identical package with and without `--dry-run` and getting byte-identical failure payloads.
|
|
98
|
+
*/
|
|
99
|
+
export function parseDestructiveResult(deploy, deletes, mode) {
|
|
100
|
+
const key = (d) => `${d.typeName}:${d.fullName}`;
|
|
101
|
+
if (deploy.timedOut) {
|
|
102
|
+
return {
|
|
103
|
+
status: deploy.status,
|
|
104
|
+
failed: true,
|
|
105
|
+
unattributed: [],
|
|
106
|
+
packageNote: mode === "dry-run"
|
|
107
|
+
? "the destructive validation was killed at timeout — nothing was checked and nothing was deleted"
|
|
108
|
+
: "the destructive deploy was killed at timeout — AMBIGUOUS; the post-projection is the authority",
|
|
109
|
+
outcomes: deletes.map((d) => ({
|
|
110
|
+
component: key(d),
|
|
111
|
+
status: mode === "dry-run"
|
|
112
|
+
? "TIMED OUT — the validation did not come back, so nothing here was checked. Nothing was deleted."
|
|
113
|
+
: "TIMED OUT (AMBIGUOUS — post-projection is the authority)"
|
|
114
|
+
}))
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const problems = problemsOf(deploy);
|
|
118
|
+
const states = statesOf(deploy);
|
|
119
|
+
const claimed = new Set();
|
|
120
|
+
const outcomes = deletes.map((d) => {
|
|
121
|
+
// Direct hit first: a blocked delete keys its diagnostic to the component itself.
|
|
122
|
+
let mine = problems.find((p) => p.fullName === d.fullName);
|
|
123
|
+
// Otherwise the diagnostic is filed against destructiveChanges.xml and names the member in prose.
|
|
124
|
+
if (!mine)
|
|
125
|
+
mine = problems.find((p) => p.fullName !== d.fullName && namesMember(p.problem, d.fullName));
|
|
126
|
+
if (mine) {
|
|
127
|
+
claimed.add(mine);
|
|
128
|
+
return {
|
|
129
|
+
component: key(d),
|
|
130
|
+
status: mode === "dry-run"
|
|
131
|
+
? `WOULD FAIL — ${mine.problem} (${mine.problemType}). Nothing was deleted.`
|
|
132
|
+
: `FAILED — ${mine.problem} (${mine.problemType})`
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const state = states.get(d.fullName);
|
|
136
|
+
if (state === "Deleted") {
|
|
137
|
+
return {
|
|
138
|
+
component: key(d),
|
|
139
|
+
status: mode === "dry-run"
|
|
140
|
+
? "VALIDATED — Salesforce accepted this deletion in a practice run. Nothing was deleted."
|
|
141
|
+
: "Deleted"
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// Silence is not acceptance. A member the response never mentions has not been validated, and
|
|
145
|
+
// saying so is the difference between this and the NOT VALIDATED blanket it replaces.
|
|
146
|
+
return {
|
|
147
|
+
component: key(d),
|
|
148
|
+
status: `NOT REPORTED — the org's response named neither this component nor a problem with it` +
|
|
149
|
+
`${state ? ` (state: ${state})` : ""}. ` +
|
|
150
|
+
(mode === "dry-run" ? "Treat it as unchecked, not as accepted." : "Read the post-projection to see what happened.")
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
const unattributed = problems.filter((p) => !claimed.has(p));
|
|
154
|
+
const anyProblem = outcomes.some((o) => !/^(VALIDATED|Deleted)/.test(o.status));
|
|
155
|
+
const failed = deploy.status !== "Succeeded" || anyProblem || unattributed.length > 0;
|
|
156
|
+
const notes = [];
|
|
157
|
+
if (failed && outcomes.some((o) => /^VALIDATED/.test(o.status))) {
|
|
158
|
+
// rollbackOnError is on by default, so a package with one bad member deletes NOTHING — a
|
|
159
|
+
// per-component VALIDATED beside a failure must not read as "that one is going through".
|
|
160
|
+
notes.push("the destructive package as submitted was REJECTED, so nothing would be deleted even where an " +
|
|
161
|
+
"individual member validated — the whole package rolls back on any failure");
|
|
162
|
+
}
|
|
163
|
+
if (unattributed.length > 0) {
|
|
164
|
+
notes.push(`${unattributed.length} destructive failure(s) could not be pinned to a requested member: ` +
|
|
165
|
+
unattributed.map((p) => `${p.fullName}: ${p.problem}`).join("; "));
|
|
166
|
+
}
|
|
167
|
+
return { status: deploy.status, outcomes, failed, unattributed, packageNote: notes.join("; ") };
|
|
168
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* Every custom API token these observations vouch for.
|
|
5
|
+
*
|
|
6
|
+
* An object in SCOPE is not an object that EXISTS. Projection objects carry no found flag, so
|
|
7
|
+
* adding every scoped name made the referential tier blind to exactly the reference it is for:
|
|
8
|
+
* on a greenfield batch the supervisor scopes the objects being BUILT, so their names became
|
|
9
|
+
* "known" before anything created them. Live (CF78 REQ-15515): an analyzer referenced a sibling's
|
|
10
|
+
* CustomTab in <tabs> and correctly expected REF_UNKNOWN; nothing fired, and it recorded the
|
|
11
|
+
* discrepancy by hand. A typo'd object reference has been unflaggable on every greenfield run.
|
|
12
|
+
*
|
|
13
|
+
* The evidence is already there: a scoped object that does not exist is ALSO listed as a
|
|
14
|
+
* CustomObject component with found:false.
|
|
15
|
+
*/
|
|
16
|
+
export function knownTokensFrom(components, objectApiNames) {
|
|
17
|
+
const known = new Set();
|
|
18
|
+
const provenAbsent = new Set(components.filter((c) => c.typeName === "CustomObject" && !c.found).map((c) => c.fullName));
|
|
19
|
+
for (const name of objectApiNames) {
|
|
20
|
+
if (provenAbsent.has(name))
|
|
21
|
+
continue;
|
|
22
|
+
known.add(name);
|
|
23
|
+
}
|
|
24
|
+
// NOTE: this loop is deliberately a SIBLING of the objects loop, not nested inside it.
|
|
25
|
+
// Nested (the original shape) it ran once per object — so a components-only projection
|
|
26
|
+
// (zero objects in scope) collected NO component tokens at all and every custom reference
|
|
27
|
+
// false-flagged as REF_UNKNOWN, while an N-object projection did N x redundant work.
|
|
28
|
+
for (const component of components) {
|
|
29
|
+
if (!component.found)
|
|
30
|
+
continue;
|
|
31
|
+
known.add(component.fullName);
|
|
32
|
+
if (component.typeName === "CustomField") {
|
|
33
|
+
// Fields are referenced both bare (Status__c) and qualified (Obj__c.Status__c).
|
|
34
|
+
const field = component.fullName.split(".")[1];
|
|
35
|
+
if (field)
|
|
36
|
+
known.add(field);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return known;
|
|
40
|
+
}
|
|
41
|
+
export class ProjectionNotReadableError extends Error {
|
|
42
|
+
}
|
|
43
|
+
/** Read a projection directory's manifest (the `dir` project_org returned). */
|
|
44
|
+
export async function readProjectionManifest(projectionDir) {
|
|
45
|
+
const manifestPath = path.join(path.resolve(projectionDir), "projection.json");
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = await fs.readFile(manifestPath, "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new ProjectionNotReadableError(`No projection.json under ${projectionDir} — pass the directory project_org returned (its \`dir\`), or omit projection/projectionDir to ground against every projection this session holds.`);
|
|
52
|
+
}
|
|
53
|
+
return JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Values the ORG enforces but the SCHEMA cannot: elements typed as a free string (or not declared
|
|
57
|
+
* at all) whose accepted values are decided by the org's own contents.
|
|
58
|
+
*
|
|
59
|
+
* `CustomTab.motif` is `string` in the artifact; `FlexiPage.componentName` is not declared at all
|
|
60
|
+
* and could not be — the legal set depends on which managed packages are installed. So no static
|
|
61
|
+
* table can validate them, and a wrong value lints perfectly green and fails at deploy, taking its
|
|
62
|
+
* whole wave down with it (a deploy is all-or-nothing). CF77 hit this on the utility bar: one
|
|
63
|
+
* analyzer spent 10 minutes and 21 tool calls hunting the Tooling API for legal component tokens
|
|
64
|
+
* and returned "could not verify".
|
|
65
|
+
*
|
|
66
|
+
* The answer is already in the org. A component of the same type that is ALREADY THERE is proof
|
|
67
|
+
* the value it uses is accepted — CF76's utility bar had been deployed to this very org, so
|
|
68
|
+
* `console:history` was sitting in a live FlexiPage the whole time. Harvesting those values from
|
|
69
|
+
* the projections a session already holds costs no extra org call and works on a fresh install
|
|
70
|
+
* with no history.
|
|
71
|
+
*/
|
|
72
|
+
export const ORG_ENFORCED_ELEMENTS = new Set(["motif", "componentName"]);
|
|
73
|
+
/** Harvest a snapshot's org-enforced values into `into`, keyed `<typeName>.<element>`. */
|
|
74
|
+
export function harvestProvenInto(node, into, typeName) {
|
|
75
|
+
if (Array.isArray(node)) {
|
|
76
|
+
for (const el of node)
|
|
77
|
+
harvestProvenInto(el, into, typeName);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!node || typeof node !== "object")
|
|
81
|
+
return;
|
|
82
|
+
for (const [k, v] of Object.entries(node)) {
|
|
83
|
+
if (ORG_ENFORCED_ELEMENTS.has(k) && typeof v === "string" && v !== "") {
|
|
84
|
+
const key = `${typeName}.${k}`;
|
|
85
|
+
let set = into.get(key);
|
|
86
|
+
if (!set)
|
|
87
|
+
into.set(key, (set = new Set()));
|
|
88
|
+
set.add(v);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
harvestProvenInto(v, into, typeName);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|