tadforce 0.4.0 → 0.4.1
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/tadforce/references/workflow.md +6 -5
- package/skills/tadforce-review/SKILL.md +2 -0
- package/skills/tadforce-update/SKILL.md +6 -4
- package/src/build/directives.js +16 -11
- package/src/build/figures.js +1 -1
- package/src/build/render.js +8 -5
- package/src/commands/docs.js +34 -13
- package/src/commands/doctor.js +134 -38
- package/src/commands/org.js +75 -11
- package/src/commands/spec.js +19 -6
- package/src/commands/update.js +54 -23
- package/src/facts/extractors/misc.js +2 -0
- package/src/facts/heuristics/exclusions.js +13 -1
- package/src/facts/heuristics/modules.js +5 -3
- package/src/facts/index.js +20 -1
- package/src/sf/sf.js +28 -4
- package/src/util/config.js +4 -1
- package/src/util/i18n.js +10 -0
- package/src/validate/index.js +3 -2
- package/templates/html/editor.js +25 -0
- package/templates/i18n/en.json +8 -2
- package/templates/i18n/fr.json +8 -2
- package/templates/manifest/package-tad.xml +4 -104
- package/templates/specs/sections.yml +3 -1
- package/templates/tad/en/02-purpose.md +15 -2
- package/templates/tad/fr/02-purpose.md +15 -2
package/src/commands/update.js
CHANGED
|
@@ -61,20 +61,33 @@ function sectionLocations(lang) {
|
|
|
61
61
|
return out;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// Sections whose spec names no facts are matched by their chapter.
|
|
65
|
+
const CHAPTER_AREAS = { data: ['data'], application: ['apex', 'automation'], ui: ['ui'], integration: ['integration'], security: ['security'], reporting: ['misc'] };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sections whose spec reads a facts area that changed. A component change (« changed classes:Foo ») makes a
|
|
69
|
+
* section relevant; a moved count only matters where the spec reads that count.
|
|
70
|
+
*/
|
|
65
71
|
export function affectedSections(areas, specs, locations) {
|
|
66
72
|
const hits = [];
|
|
67
73
|
for (const [id, s] of Object.entries(specs)) {
|
|
68
74
|
const reasons = [];
|
|
69
|
-
|
|
75
|
+
const paths = s.facts && s.facts.length ? s.facts : (CHAPTER_AREAS[id.split('.')[0]] || []);
|
|
76
|
+
for (const p of paths) {
|
|
70
77
|
const fp = p.replace(/^counts\./, '');
|
|
71
|
-
for (const [area,
|
|
72
|
-
|
|
78
|
+
for (const [area, whys] of Object.entries(areas)) {
|
|
79
|
+
for (const why of whys) {
|
|
80
|
+
const isCount = why.startsWith('count ');
|
|
81
|
+
if (p === 'counts' ? isCount : fp === area || fp.startsWith(area + '.') || area.startsWith(fp + '.') || (p === 'modules' && area === 'modules')) reasons.push(why);
|
|
82
|
+
}
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
|
-
if (reasons.length)
|
|
85
|
+
if (!reasons.length) continue;
|
|
86
|
+
const unique = [...new Set(reasons)];
|
|
87
|
+
hits.push({ id, ...(locations[id] || {}), relevance: unique.some((r) => !r.startsWith('count ')) ? 'components' : 'counts', reasons: unique.slice(0, 12) });
|
|
76
88
|
}
|
|
77
|
-
|
|
89
|
+
// what changed in the components first: count-only sections usually need no rewrite
|
|
90
|
+
return hits.sort((a, b) => (a.relevance === b.relevance ? 0 : a.relevance === 'components' ? -1 : 1));
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
const nextVersion = (v) => { const m = /^(\d+)\.(\d+)$/.exec(String(v)); return m ? `${m[1]}.${Number(m[2]) + 1}` : `${v}.1`; };
|
|
@@ -86,25 +99,21 @@ export async function run(args) {
|
|
|
86
99
|
if (!exists(before)) throw new Error('No previous facts: run `tadforce doctor` and `tadforce build` first.');
|
|
87
100
|
say(() => log.banner('update'));
|
|
88
101
|
|
|
89
|
-
// 1.
|
|
102
|
+
// 1. What the current version was written from (archived below, once something changed).
|
|
90
103
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
91
|
-
const archive = ctx.p('facts', 'history', stamp);
|
|
92
|
-
fs.mkdirSync(archive, { recursive: true });
|
|
93
104
|
const oldFacts = readJson(before);
|
|
94
105
|
const oldPrints = exists(ctx.p('facts', 'fingerprints.json')) ? readJson(ctx.p('facts', 'fingerprints.json')) : fingerprints(sourceDirs(ctx));
|
|
95
|
-
writeJson(path.join(archive, 'facts.json'), oldFacts);
|
|
96
|
-
writeJson(path.join(archive, 'fingerprints.json'), oldPrints);
|
|
97
|
-
say(() => log.ok(`Previous facts archived in ${path.relative(ctx.project, archive)}`));
|
|
98
106
|
|
|
99
107
|
// 2. Read the org again (a fresh snapshot: deleted components must disappear too).
|
|
100
108
|
const mode = ctx.cfg.source.mode || 'org';
|
|
101
|
-
|
|
109
|
+
// in --json mode stdout carries only the report; progress goes to stderr
|
|
110
|
+
const quiet = async (fn) => { if (!args.json) return fn(); const o = console.log; console.log = (...a) => process.stderr.write(a.join(' ') + '\n'); try { return await fn(); } finally { console.log = o; } };
|
|
102
111
|
if (mode !== 'local' && !args.skipRetrieve) {
|
|
103
112
|
const snap = ctx.p(ctx.cfg.source.dir);
|
|
104
113
|
if (exists(snap)) fs.rmSync(snap, { recursive: true, force: true });
|
|
105
114
|
for (const fn of [org.manifest, org.retrieve]) { const code = await quiet(() => fn({ ...args, cwd: ctx.project })); if (code) return code; }
|
|
106
115
|
}
|
|
107
|
-
await quiet(() => docs.facts({ ...args, cwd: ctx.project, org: mode === 'local' || args.skipRetrieve ? false : ctx.cfg.org.alias }));
|
|
116
|
+
await quiet(() => docs.facts({ ...args, json: false, cwd: ctx.project, org: mode === 'local' || args.skipRetrieve ? false : ctx.cfg.org.alias }));
|
|
108
117
|
const ctx2 = loadWorkspace(ctx.project);
|
|
109
118
|
const newPrints = fingerprints(sourceDirs(ctx2));
|
|
110
119
|
writeJson(ctx2.p('facts', 'fingerprints.json'), newPrints);
|
|
@@ -122,33 +131,55 @@ export async function run(args) {
|
|
|
122
131
|
for (const m of modulesAfter.filter((x) => !modulesBefore.includes(x))) note('modules', `new product detected: ${m}`);
|
|
123
132
|
for (const m of modulesBefore.filter((x) => !modulesAfter.includes(x))) note('modules', `product no longer detected: ${m}`);
|
|
124
133
|
const specs = readYaml(path.join(TEMPLATES, 'specs', 'sections.yml'), {}).sections || {};
|
|
125
|
-
|
|
134
|
+
// sections still to write are not « to rewrite »: their first writing reads the new facts anyway
|
|
135
|
+
const srcText = (file) => (exists(ctx2.p(file)) ? readText(ctx2.p(file)) : '');
|
|
136
|
+
const sections = affectedSections(areas, specs, sectionLocations(templateLang(ctx2.cfg.language)))
|
|
137
|
+
.filter((x) => !x.file || !new RegExp(`<!--\\s*tad:pending\\s+${x.id.replace(/\./g, '\\.')}\\s*-->`).test(srcText(x.file)));
|
|
138
|
+
const upToDate = !(diff.added.length + diff.changed.length + diff.removed.length) && !Object.keys(counts).length;
|
|
139
|
+
|
|
140
|
+
// the facts of the previous version, kept once per version: never overwritten by a later run
|
|
141
|
+
const archive = ctx2.p('facts', 'history', `v${ctx2.cfg.document.version}`);
|
|
142
|
+
if (!upToDate && !exists(archive)) {
|
|
143
|
+
writeJson(path.join(archive, 'facts.json'), oldFacts);
|
|
144
|
+
writeJson(path.join(archive, 'fingerprints.json'), oldPrints);
|
|
145
|
+
say(() => log.ok(`Facts of version ${ctx2.cfg.document.version} archived in ${path.relative(ctx2.project, archive)}`));
|
|
146
|
+
}
|
|
126
147
|
|
|
127
148
|
// 4. New version in the document control.
|
|
128
149
|
const cfg = ctx2.cfg;
|
|
129
150
|
const total = diff.added.length + diff.changed.length + diff.removed.length;
|
|
130
151
|
const from = cfg.document.version;
|
|
131
152
|
if (total && args.bump !== false) {
|
|
132
|
-
const by = [cfg.document.author, cfg.integrator.name].filter(Boolean).join(' — ') ||
|
|
153
|
+
const by = [cfg.document.author, cfg.integrator.name].filter(Boolean).join(' — ') || '—';
|
|
133
154
|
const history = cfg.document.history && cfg.document.history.length ? cfg.document.history : [{ version: from, date: cfg.document.date, author: by, purpose: cfg.language === 'fr' ? 'Version initiale' : 'Initial version' }];
|
|
134
155
|
cfg.document.version = nextVersion(from);
|
|
135
156
|
cfg.document.date = stamp;
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
157
|
+
const fr = cfg.language === 'fr';
|
|
158
|
+
const parts = [[diff.added.length, fr ? 'ajouté(s)' : 'added'], [diff.changed.length, fr ? 'modifié(s)' : 'changed'], [diff.removed.length, fr ? 'supprimé(s)' : 'removed']].filter(([n]) => n).map(([n, w]) => `${n} ${w}`);
|
|
159
|
+
const purpose = `${fr ? 'Mise à jour, composants' : 'Update, components'} : ${parts.join(', ')}`.replace(/^Update, components :/, 'Update, components:');
|
|
139
160
|
cfg.document.history = [...history, { version: cfg.document.version, date: stamp, author: by, purpose }];
|
|
140
161
|
ctx2.save(cfg);
|
|
141
162
|
}
|
|
142
|
-
const report = { from, to: cfg.document.version, changes: diff, counts, sections, next: sections.length ? 'rewrite
|
|
143
|
-
|
|
163
|
+
const report = { upToDate, from, to: cfg.document.version, changes: diff, counts, sections, next: upToDate ? null : sections.length ? 'rewrite the sections whose relevance is « components » from the new facts (keep heading and anchor); check the « counts » ones for numbers written in the text; then `npx tadforce build`' : `npx tadforce build --cwd "${ctx2.project}"` };
|
|
164
|
+
// a rerun before the rewrite and the build keeps the work to do of the previous update
|
|
165
|
+
const prevFile = ctx2.p('facts', 'update.json');
|
|
166
|
+
const prev = exists(prevFile) ? readJson(prevFile) : null;
|
|
167
|
+
if (upToDate && prev && !prev.built && prev.to === cfg.document.version && (prev.sections || []).length) {
|
|
168
|
+
const carried = { ...prev, carried: true, next: prev.next };
|
|
169
|
+
if (args.json) { console.log(JSON.stringify(carried, null, 2)); return 0; }
|
|
170
|
+
log.ok(`Nothing new since the update to ${prev.to}, whose ${prev.sections.length} section(s) are still to rewrite:`);
|
|
171
|
+
for (const s of prev.sections) console.log(` ${c.cyan(s.id.padEnd(28))} ${c.dim((s.file || '') + (s.anchor ? '#' + s.anchor : ''))}`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
writeJson(prevFile, report);
|
|
144
175
|
if (args.json) { console.log(JSON.stringify(report, null, 2)); return 0; }
|
|
145
176
|
|
|
146
|
-
if (
|
|
177
|
+
if (upToDate) { log.ok('Nothing changed since the last version. The document is up to date.'); return 0; }
|
|
147
178
|
log.ok(`${diff.added.length} added · ${diff.changed.length} changed · ${diff.removed.length} removed · ${Object.keys(counts).length} count(s) moved`);
|
|
148
179
|
for (const [kind, list] of Object.entries(diff)) if (list.length) console.log(` ${c.bold(kind)}: ${list.slice(0, 15).map((x) => `${x.folder}/${x.name}`).join(', ')}${list.length > 15 ? c.dim(` … +${list.length - 15}`) : ''}`);
|
|
149
180
|
if (total) log.ok(`Version ${from} → ${cfg.document.version} (history updated)`);
|
|
150
181
|
log.step(`${sections.length} section(s) to review:`);
|
|
151
|
-
for (const s of sections) console.log(` ${c.cyan(s.id.padEnd(28))} ${c.dim((s.file || '') + (s.anchor ? '#' + s.anchor : ''))} ${s.reasons.slice(0, 2).join('; ')}`);
|
|
182
|
+
for (const s of sections) console.log(` ${s.relevance === 'components' ? c.cyan(s.id.padEnd(28)) : c.dim(s.id.padEnd(28))} ${c.dim((s.file || '') + (s.anchor ? '#' + s.anchor : ''))} ${s.reasons.slice(0, 2).join('; ')}`);
|
|
152
183
|
log.human('let your agent rewrite these sections (/tadforce-update), then `tadforce build`. Numbers and generated tables update by themselves.');
|
|
153
184
|
return 0;
|
|
154
185
|
}
|
|
@@ -25,6 +25,8 @@ export function extractMisc(roots) {
|
|
|
25
25
|
genAi: count('genAiPlanners', '') + count('genAiPlannerBundles', '') + count('genAiPlugins', '') + count('genAiFunctions', '') + bundles(roots, 'aiAuthoringBundles').length,
|
|
26
26
|
entitlementProcesses: count('entitlementProcesses', ''),
|
|
27
27
|
dataStreams: count('dataStreamDefinitions', ''),
|
|
28
|
+
// every org has a PlatformEncryption.settings file: only switched-on options are evidence of Shield
|
|
29
|
+
platformEncryption: files(roots, 'settings', 'PlatformEncryption.settings-meta.xml').reduce((n, f) => n + (safeRead(f).match(/<enable\w*>true</g) || []).length, 0),
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
// Components documented by name only (routing, channels, sites, paths, rules, agents…)
|
|
@@ -26,6 +26,7 @@ export const COLLECTIONS = [
|
|
|
26
26
|
['namedCredential', (f) => f.integration.namedCredentials],
|
|
27
27
|
['externalCredential', (f) => f.integration.externalCredentials],
|
|
28
28
|
['remoteSite', (f) => f.integration.remoteSites],
|
|
29
|
+
['cspTrustedSite', (f) => f.integration.cspTrustedSites],
|
|
29
30
|
['connectedApp', (f) => f.integration.connectedApps],
|
|
30
31
|
['authProvider', (f) => f.integration.authProviders],
|
|
31
32
|
['report', (f) => f.misc.reports.names, null],
|
|
@@ -64,11 +65,21 @@ export function applyExclusions(facts, knowledge = {}) {
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
}
|
|
68
|
+
// the tests of an excluded class leave with it (LegacyRateHelper → LegacyRateHelperTest, …_Test, …Tests)
|
|
69
|
+
const gone = new Set(applied.filter((a) => a.type === 'apex').map((a) => String(a.name).toLowerCase()));
|
|
70
|
+
const classes = facts.apex.classes;
|
|
71
|
+
for (let i = classes.length - 1; i >= 0; i--) {
|
|
72
|
+
const base = String(classes[i].name).toLowerCase().replace(/_?tests?$/, '');
|
|
73
|
+
if (classes[i].test && base !== String(classes[i].name).toLowerCase() && gone.has(base)) {
|
|
74
|
+
applied.push({ type: 'apex', name: classes[i].name, reason: `test of an excluded class`, feature: null });
|
|
75
|
+
classes.splice(i, 1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
67
78
|
return applied.sort((a, b) => (a.type + a.name).localeCompare(b.type + b.name));
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
// Org-wide components (profiles, roles, queues, report folders…) are shared by every project: never scoped out.
|
|
71
|
-
const SCOPED_TYPES = new Set(['apex', 'trigger', 'lwc', 'aura', 'vfPage', 'flexipage', 'app', 'tab', 'quickAction', 'flow', 'omniScript', 'integrationProcedure', 'dataMapper', 'flexCard', 'object', 'cmdt', 'permissionSet', 'permissionSetGroup', 'namedCredential', 'externalCredential', 'remoteSite', 'connectedApp', 'authProvider', 'staticResource']);
|
|
82
|
+
const SCOPED_TYPES = new Set(['apex', 'trigger', 'lwc', 'aura', 'vfPage', 'flexipage', 'app', 'tab', 'quickAction', 'flow', 'omniScript', 'integrationProcedure', 'dataMapper', 'flexCard', 'object', 'cmdt', 'permissionSet', 'permissionSetGroup', 'namedCredential', 'externalCredential', 'remoteSite', 'cspTrustedSite', 'connectedApp', 'authProvider', 'staticResource']);
|
|
72
83
|
|
|
73
84
|
/**
|
|
74
85
|
* Keep only the part of the org a project owns (tadforce.config.json › scope.include, name patterns with `*`).
|
|
@@ -115,6 +126,7 @@ export function exclusionCandidates(facts, refs) {
|
|
|
115
126
|
for (const item of list) {
|
|
116
127
|
const name = keyOf(item, field);
|
|
117
128
|
if (type === 'apex' && item.test) continue;
|
|
129
|
+
if (type === 'object' && !String(name).includes('__')) continue; // standard objects (AiEval…TestCase, Devops…Test) are Salesforce's
|
|
118
130
|
const bad = nameWords(name).filter((w) => SUSPICIOUS.has(w));
|
|
119
131
|
if (bad.length) add(type, name, `name suggests ${bad.join('/')}`);
|
|
120
132
|
}
|
|
@@ -35,9 +35,9 @@ const CLOUDS = [
|
|
|
35
35
|
{ id: 'netZero', name: 'Net Zero Cloud', ref: 'industries', objects: ['StnryAssetCrbnFtprnt', 'VehicleAssetCrbnFtprnt', 'Scope3CrbnFtprnt'] },
|
|
36
36
|
{ id: 'omnistudio', name: 'OmniStudio', ref: 'omnistudio', ns: ['omnistudio', 'vlocity_cmt', 'vlocity_ins', 'vlocity_ps'], test: (f) => Object.values(f.automation.omnistudio).some((l) => l.length > 0) },
|
|
37
37
|
{ id: 'dataCloud', name: 'Data Cloud', ref: 'data-cloud', test: (f) => f.misc.signals.dataStreams > 0 || f.data.objects.some((o) => /__(dlm|dll)$/.test(o.name)) || (f.misc.folders.dataStreamDefinitions || 0) + (f.misc.folders.dataSourceObjects || 0) > 0 },
|
|
38
|
-
{ id: 'agentforce', name: 'Agentforce / Einstein', ref: 'agentforce-ai', test: (f) => f.misc.signals.bots + f.misc.signals.genAi > 0 || (f.misc.folders.genAiPromptTemplates || 0) + (f.misc.folders.mlPredictionDefinitions || 0) > 0 },
|
|
38
|
+
{ id: 'agentforce', name: 'Agentforce / Einstein', ref: 'agentforce-ai', psg: /Agentforce|Copilot|EinsteinGPT|GenAI|Einstein_?Agent/i, test: (f) => f.misc.signals.bots + f.misc.signals.genAi > 0 || (f.misc.folders.genAiPromptTemplates || 0) + (f.misc.folders.mlPredictionDefinitions || 0) > 0 },
|
|
39
39
|
{ id: 'crmAnalytics', name: 'CRM Analytics', ref: 'analytics', ns: ['wave'], test: (f) => ['wave', 'waveDashboards', 'waveDataflows', 'waveRecipes', 'waveApplications', 'waveDatasets'].some((k) => f.misc.folders[k] > 0) },
|
|
40
|
-
{ id: 'shield', name: 'Shield (encryption, event monitoring, audit)', ref: 'security-shield', test: (f) => ['transactionSecurityPolicies', 'eventSubscriptions'].some((k) => f.misc.folders[k] > 0) || f.misc.
|
|
40
|
+
{ id: 'shield', name: 'Shield (encryption, event monitoring, audit)', ref: 'security-shield', test: (f) => ['transactionSecurityPolicies', 'eventSubscriptions'].some((k) => f.misc.folders[k] > 0) || (f.misc.signals.platformEncryption || 0) > 0 },
|
|
41
41
|
{ id: 'slack', name: 'Slack integration', ref: 'integration-platforms', ns: ['slackv2', 'slack'], test: (f) => (f.misc.folders.slackApps || 0) > 0 },
|
|
42
42
|
{ id: 'events', name: 'Platform Events / Change Data Capture', ref: 'platform-events', test: (f) => f.data.objects.some((o) => o.kind === 'platformEvent') || f.data.cdc.length > 0 },
|
|
43
43
|
];
|
|
@@ -53,7 +53,7 @@ function usedStandard(f) {
|
|
|
53
53
|
return used;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function detectModules(f) {
|
|
56
|
+
export function detectModules(f, org = null) {
|
|
57
57
|
const ns = new Set(f.namespaces.map((n) => n.namespace));
|
|
58
58
|
const std = usedStandard(f);
|
|
59
59
|
const clouds = [];
|
|
@@ -63,6 +63,8 @@ export function detectModules(f) {
|
|
|
63
63
|
for (const n of c.ns || []) if (ns.has(n)) evidence.push(`namespace ${n}`);
|
|
64
64
|
for (const o of c.objects || []) if (std.has(o) || f.data.objects.some((x) => x.name === o && x.kind === 'standard' && x.customFields > 0)) evidence.push(`object ${o}`);
|
|
65
65
|
if (c.test && c.test(f)) evidence.push('metadata');
|
|
66
|
+
// users assigned to the product's permission set groups (aggregated org facts) prove it is used
|
|
67
|
+
for (const g of (org && org.psgAssignments) || []) if (c.psg && c.psg.test(String(g.group)) && g.users > 0) evidence.push(`permission set group ${g.group} (${g.users} users)`);
|
|
66
68
|
const on = evidence.length > 0;
|
|
67
69
|
flags[c.id] = on;
|
|
68
70
|
if (on) clouds.push({ id: c.id, name: c.name, reference: c.ref, evidence: [...new Set(evidence)].slice(0, 6) });
|
package/src/facts/index.js
CHANGED
|
@@ -36,6 +36,7 @@ export function extractFacts(dirs, knowledge = {}, opts = {}) {
|
|
|
36
36
|
const refs = references(roots, apex, ui, automation, integration);
|
|
37
37
|
const facts = { schema: FACTS_SCHEMA, apex, data, automation, ui, security, integration, misc, namespaces };
|
|
38
38
|
|
|
39
|
+
const shipped = removeSalesforceShipped(facts);
|
|
39
40
|
const outOfScope = applyScope(facts, knowledge.scope || {});
|
|
40
41
|
const applied = applyExclusions(facts, knowledge.exclusions || {});
|
|
41
42
|
// placements are recomputed so that excluded containers no longer count as usages
|
|
@@ -51,7 +52,8 @@ export function extractFacts(dirs, knowledge = {}, opts = {}) {
|
|
|
51
52
|
|
|
52
53
|
facts.exclusions = { applied, candidates: exclusionCandidates(facts, refs) };
|
|
53
54
|
if ((knowledge.scope?.include || []).length) facts.scope = { include: [...knowledge.scope.include], outOfScope: outOfScope.length };
|
|
54
|
-
facts.modules = detectModules(facts);
|
|
55
|
+
facts.modules = detectModules(facts, opts.org || null);
|
|
56
|
+
if (Object.keys(shipped).length) facts.salesforceShipped = shipped;
|
|
55
57
|
// Products used without metadata traces are declared by a human in glossary.yml › products.
|
|
56
58
|
for (const p of (knowledge.glossary && knowledge.glossary.products) || []) {
|
|
57
59
|
if (!p || !p.name || facts.modules.clouds.some((c) => c.name === p.name)) continue;
|
|
@@ -74,6 +76,23 @@ export function extractFacts(dirs, knowledge = {}, opts = {}) {
|
|
|
74
76
|
return facts;
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
// Components Salesforce ships in every org (standard apps, platform permission set groups…): not the project's architecture.
|
|
80
|
+
const SHIPPED = /^(standard__|force__|sfdcInternalInt__|sf_devops)/;
|
|
81
|
+
|
|
82
|
+
/** Set aside what Salesforce ships with the org; returns how many of each kind, by facts path. */
|
|
83
|
+
function removeSalesforceShipped(f) {
|
|
84
|
+
const out = {};
|
|
85
|
+
// the utility bars of Salesforce's own apps go with them
|
|
86
|
+
const bars = new Set(f.ui.apps.filter((x) => SHIPPED.test(String(x.name)) && x.utilityBar).map((x) => x.utilityBar));
|
|
87
|
+
for (let i = f.ui.flexipages.length - 1; i >= 0; i--) if (bars.has(f.ui.flexipages[i].name)) { f.ui.flexipages.splice(i, 1); out['ui.utilityBars'] = (out['ui.utilityBars'] || 0) + 1; }
|
|
88
|
+
for (const [key, list] of [['ui.apps', f.ui.apps], ['ui.tabs', f.ui.tabs], ['ui.flexipages', f.ui.flexipages], ['security.permissionSets', f.security.permissionSets], ['security.permissionSetGroups', f.security.permissionSetGroups]]) {
|
|
89
|
+
let n = 0;
|
|
90
|
+
for (let i = list.length - 1; i >= 0; i--) if (SHIPPED.test(String(list[i].name))) { list.splice(i, 1); n++; }
|
|
91
|
+
if (n) out[key] = n;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
77
96
|
/** Cross-references: where Apex classes, LWC/Aura and FlexiPages are used. */
|
|
78
97
|
function references(roots, apex, ui, automation, integration) {
|
|
79
98
|
const nonTest = apex.classes.filter((c) => !c.test);
|
package/src/sf/sf.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Read-only wrapper around the Salesforce CLI. Every call goes through an allow-list:
|
|
2
2
|
// TADForce never deploys, never deletes, never reads record-level data.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
3
6
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
7
|
|
|
5
8
|
const ALLOWED = [
|
|
@@ -16,6 +19,8 @@ const ALLOWED = [
|
|
|
16
19
|
['package', 'installed', 'list'],
|
|
17
20
|
['config', 'get'],
|
|
18
21
|
];
|
|
22
|
+
// Read-only commands that take positional arguments after their subcommand words (`sf config get target-org`).
|
|
23
|
+
const WITH_POSITIONALS = [['config', 'get']];
|
|
19
24
|
|
|
20
25
|
// sObjects whose rows describe the org itself (configuration, not business data).
|
|
21
26
|
const METADATA_SOBJECTS = new Set([
|
|
@@ -30,7 +35,8 @@ export function isAllowed(args) {
|
|
|
30
35
|
const cmd = [];
|
|
31
36
|
for (const a of args) { if (a.startsWith('-')) break; cmd.push(a); }
|
|
32
37
|
if (!cmd.length) return args[0] === '--version';
|
|
33
|
-
|
|
38
|
+
if (ALLOWED.some((p) => p.length === cmd.length && p.every((w, i) => w === cmd[i]))) return true;
|
|
39
|
+
return WITH_POSITIONALS.some((p) => cmd.length > p.length && p.every((w, i) => w === cmd[i]) && cmd.slice(p.length).every((a) => /^[\w-]+$/.test(a)));
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
const AGG = /^(count\(\s*(id)?\s*\)|max\(\s*(createddate|lastmodifieddate)\s*\)|min\(\s*createddate\s*\))(\s+\w+)?$/i;
|
|
@@ -66,8 +72,21 @@ function launch(args, opts) {
|
|
|
66
72
|
return [[resolveSf(), ...args].map(quote).join(' '), [], { ...opts, shell: true }];
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
// Network hiccups (DNS, resets) are retried; the CLI reports a failed DNS lookup as DomainNotFoundError.
|
|
76
|
+
const TRANSIENT = /DomainNotFound|ENOTFOUND|EAI_AGAIN|ECONNRESET|ETIMEDOUT|socket hang up|getaddrinfo/i;
|
|
77
|
+
export const isTransient = (e) => TRANSIENT.test(`${e && e.name} ${e && e.message} ${e && e.sf && e.sf.name}`);
|
|
78
|
+
|
|
79
|
+
/** Run `sf ... --json` and return the parsed result (throws with the CLI message on failure). Transient network errors are retried twice. */
|
|
80
|
+
export async function sfJson(args, opts = {}) {
|
|
81
|
+
for (let attempt = 0; ; attempt++) {
|
|
82
|
+
try { return await sfJsonOnce(args, opts); } catch (e) {
|
|
83
|
+
if (attempt >= 2 || !isTransient(e)) throw e;
|
|
84
|
+
await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sfJsonOnce(args, { timeoutMs = 600000, cwd } = {}) {
|
|
71
90
|
if (!isAllowed(args)) throw new Error(`TADForce refuses to run: sf ${args.join(' ')}`);
|
|
72
91
|
if (args[0] === 'data' && args[1] === 'query') {
|
|
73
92
|
const qi = args.findIndex((a) => a === '--query' || a === '-q');
|
|
@@ -105,12 +124,17 @@ export function sfInteractive(args) {
|
|
|
105
124
|
return r.status === 0;
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
// `sf --version` takes seconds: its answer is kept a day (per CLI binary), outside any project.
|
|
128
|
+
const VERSION_CACHE = path.join(os.tmpdir(), 'tadforce-sf-version.json');
|
|
108
129
|
export function sfVersion() {
|
|
130
|
+
try { const c = JSON.parse(fs.readFileSync(VERSION_CACHE, 'utf8')); if (c.bin === resolveSf() && Date.now() - c.at < 86400000 && c.version) return c.version; } catch { /* no cache yet */ }
|
|
109
131
|
const [cmd, argv, opts] = launch(['--version'], { encoding: 'utf8' });
|
|
110
132
|
const r = spawnSync(cmd, argv, opts);
|
|
111
133
|
if (r.status !== 0) return null;
|
|
112
134
|
const m = /@salesforce\/cli\/(\d+\.\d+\.\d+)/.exec(r.stdout || '');
|
|
113
|
-
|
|
135
|
+
const version = m ? m[1] : (r.stdout || '').trim() || null;
|
|
136
|
+
try { fs.writeFileSync(VERSION_CACHE, JSON.stringify({ bin: resolveSf(), version, at: Date.now() })); } catch { /* no writable tmp: no cache */ }
|
|
137
|
+
return version;
|
|
114
138
|
}
|
|
115
139
|
|
|
116
140
|
export function soql(query, alias, { tooling = false } = {}) {
|
package/src/util/config.js
CHANGED
|
@@ -65,7 +65,7 @@ export function findWorkspace(cwd = process.cwd()) {
|
|
|
65
65
|
export function loadWorkspace(cwd) {
|
|
66
66
|
const ws = findWorkspace(cwd);
|
|
67
67
|
if (!ws) {
|
|
68
|
-
const err = new Error(`No TADForce workspace found under ${cwd}. Run \`npx tadforce
|
|
68
|
+
const err = new Error(`No TADForce workspace found under ${cwd}. Run \`npx tadforce doctor\` in your Salesforce project first.`);
|
|
69
69
|
err.code = 'NO_WORKSPACE';
|
|
70
70
|
throw err;
|
|
71
71
|
}
|
|
@@ -82,6 +82,9 @@ export function loadWorkspace(cwd) {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** Flags for the skeletons' conditions: `<!-- tad:if source.local -->`. */
|
|
86
|
+
export const sourceFlags = (cfg) => ({ local: cfg.source.mode === 'local', org: cfg.source.mode !== 'local' });
|
|
87
|
+
|
|
85
88
|
/** Metadata source directories: the retrieved snapshot and/or the project's package dirs (source.mode). */
|
|
86
89
|
export function sourceDirs(wsCtx) {
|
|
87
90
|
const mode = wsCtx.cfg.source.mode;
|
package/src/util/i18n.js
CHANGED
|
@@ -41,6 +41,16 @@ export function t(dict, key, vars = {}) {
|
|
|
41
41
|
return s.replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined ? String(vars[k]) : m));
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const locale = (lang) => (lang === 'fr' ? 'fr-FR' : lang === 'en' || !lang ? 'en-GB' : lang);
|
|
45
|
+
|
|
46
|
+
/** 2026-10-30 → 30 October 2026 / 30 octobre 2026 / 30 de octubre de 2026. Anything else is kept as written. */
|
|
47
|
+
export function formatDate(value, lang) {
|
|
48
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || ''));
|
|
49
|
+
if (!m) return value;
|
|
50
|
+
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
51
|
+
try { return new Intl.DateTimeFormat(locale(lang), { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(d); } catch { return value; }
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
export function formatNumber(n, lang) {
|
|
45
55
|
if (typeof n !== 'number' || !Number.isFinite(n)) return String(n);
|
|
46
56
|
// fr-FR uses a narrow no-break space as thousands separator, as in the reference document.
|
package/src/validate/index.js
CHANGED
|
@@ -73,7 +73,8 @@ export function validateDocument(wsCtx, doc, { buildReport = null } = {}) {
|
|
|
73
73
|
const pend = /<!--\s*tad:pending\s+([\w.-]+)\s*-->/.exec(line);
|
|
74
74
|
// sections inside a false <!-- tad:if --> block are not part of this org's document
|
|
75
75
|
if (pend && (!buildReport || buildReport.pending.includes(pend[1]))) add('error', 'pending', `Section "${pend[1]}" is not written yet.`, where);
|
|
76
|
-
|
|
76
|
+
// upper-case markers as whole words only: « Método », « todo » (Spanish « all ») are text, not placeholders
|
|
77
|
+
if (/(?<![\p{L}\p{N}_])(TODO|TBD|FIXME)(?![\p{L}\p{N}_])/u.test(line) || /lorem ipsum/i.test(line)) add('error', 'todo', 'Placeholder text left in the document.', where);
|
|
77
78
|
for (const [id, re] of SECRET_PATTERNS) if (re.test(line)) add('error', 'secret', `Possible secret or org identifier (${id}).`, where);
|
|
78
79
|
for (const a of avoid) if (new RegExp(`\\b${escapeRe(a.avoid)}\\b`, 'i').test(line)) add('error', 'terminology', `"${a.avoid}" must be written "${a.use}" (glossary).`, where);
|
|
79
80
|
for (const fk of forbiddenFeatures) if (new RegExp(`\\b${escapeRe(fk.k)}\\b`, 'i').test(line)) add('error', 'decommissioned', `Mentions "${fk.k}" (decommissioned feature: ${fk.feature}).`, where);
|
|
@@ -91,7 +92,7 @@ export function validateDocument(wsCtx, doc, { buildReport = null } = {}) {
|
|
|
91
92
|
if (/[*_]$/.test(id) || /^\*/.test(id)) continue; // naming patterns such as `NWB_*` or `*Service`
|
|
92
93
|
if (!looksLikeComponent(id) || COMMON.has(id) || inventory.has(id)) continue;
|
|
93
94
|
if (/^[\w]+\.[\w]+$/.test(id) && inventory.has(id.split('.')[1])) continue;
|
|
94
|
-
add('warn', 'unknown-component', `\`${id}\` is not in the metadata inventory (typo, other org, or package component?).`, where);
|
|
95
|
+
add('warn', 'unknown-component', `\`${id}\` is not in the metadata inventory (${(wsCtx.cfg.scope && wsCtx.cfg.scope.include || []).length ? 'out of the project scope, ' : ''}typo, other org, or package component?).`, where);
|
|
95
96
|
}
|
|
96
97
|
});
|
|
97
98
|
if (inCode) add('error', 'unclosed-code', 'Unclosed code fence.', `src/${doc}/${f}`);
|
package/templates/html/editor.js
CHANGED
|
@@ -497,6 +497,31 @@
|
|
|
497
497
|
} catch (e) { toast(T('docxFailed', { error: e.message }), 5000); }
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
+
// A rebuilt document carries the comments, not their highlights: find each quoted passage again.
|
|
501
|
+
function reanchor(c) {
|
|
502
|
+
if (c.kind === 'zone' || !c.quote || marksOf(c.id).length) return;
|
|
503
|
+
var q = c.quote.replace(/…$/, '').trim(), probe = q.slice(0, 40);
|
|
504
|
+
if (probe.length < 6) return;
|
|
505
|
+
for (var zi = 0; zi < zones.length; zi++) {
|
|
506
|
+
if (!zones[zi]) continue;
|
|
507
|
+
var w = document.createTreeWalker(zones[zi], NodeFilter.SHOW_TEXT, null), n;
|
|
508
|
+
while ((n = w.nextNode())) {
|
|
509
|
+
if (n.parentElement.closest('svg, mark.tad-cmt')) continue;
|
|
510
|
+
var i = n.nodeValue.indexOf(probe);
|
|
511
|
+
if (i < 0) continue;
|
|
512
|
+
var r = document.createRange();
|
|
513
|
+
r.setStart(n, i); r.setEnd(n, Math.min(n.nodeValue.length, i + q.length));
|
|
514
|
+
wrapRange(r, c.id);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// the quoted words were corrected: pin the comment on its section heading instead
|
|
519
|
+
var heads = document.querySelectorAll('article.doc h1, article.doc h2, article.doc h3');
|
|
520
|
+
for (var hi = 0; hi < heads.length; hi++) {
|
|
521
|
+
if (c.section && heads[hi].textContent.indexOf(c.section) >= 0) { insertPin(heads[hi], c.id); return; }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
comments.forEach(reanchor);
|
|
500
525
|
comments.forEach(function (c) { if (c.status === 'resolved') marksOf(c.id).forEach(function (m) { m.classList.add('resolved'); }); });
|
|
501
526
|
render();
|
|
502
527
|
})();
|
package/templates/i18n/en.json
CHANGED
|
@@ -126,7 +126,8 @@
|
|
|
126
126
|
"scopeValue": "Salesforce organization ({environment}) of {client}, state as of {date}",
|
|
127
127
|
"firstVersion": "Consolidated version submitted to {client} for review and approval",
|
|
128
128
|
"preparedBy": "Prepared by",
|
|
129
|
-
"scopeLimited": "Scope limited to: {scope}"
|
|
129
|
+
"scopeLimited": "Scope limited to: {scope}",
|
|
130
|
+
"scopeValueLocal": "Salesforce DX project of {client} (metadata sources), state as of {date}"
|
|
130
131
|
},
|
|
131
132
|
"legend": {
|
|
132
133
|
"actor": "Actors: users, populations, channels",
|
|
@@ -171,7 +172,8 @@
|
|
|
171
172
|
"ec": "{n} External Credentials",
|
|
172
173
|
"rest": "{n} Apex REST services",
|
|
173
174
|
"apps2": "{n} connected apps",
|
|
174
|
-
"caption": "Logical architecture in layers"
|
|
175
|
+
"caption": "Logical architecture in layers",
|
|
176
|
+
"servicesTitle": "Services"
|
|
175
177
|
},
|
|
176
178
|
"context": {
|
|
177
179
|
"salesforce": "Salesforce organization",
|
|
@@ -362,5 +364,9 @@
|
|
|
362
364
|
"nc": "Named / External credentials",
|
|
363
365
|
"rest": "Apex REST services",
|
|
364
366
|
"reports": "Reports / dashboards"
|
|
367
|
+
},
|
|
368
|
+
"figures": {
|
|
369
|
+
"roles": "Role hierarchy",
|
|
370
|
+
"integrations": "Integration map"
|
|
365
371
|
}
|
|
366
372
|
}
|
package/templates/i18n/fr.json
CHANGED
|
@@ -126,7 +126,8 @@
|
|
|
126
126
|
"scopeValue": "Organisation Salesforce ({environment}) de {client}, état au {date}",
|
|
127
127
|
"firstVersion": "Version consolidée remise à {client} pour revue et validation",
|
|
128
128
|
"preparedBy": "Établi par",
|
|
129
|
-
"scopeLimited": "Périmètre limité à : {scope}"
|
|
129
|
+
"scopeLimited": "Périmètre limité à : {scope}",
|
|
130
|
+
"scopeValueLocal": "Projet Salesforce DX de {client} (sources de métadonnées), état au {date}"
|
|
130
131
|
},
|
|
131
132
|
"legend": {
|
|
132
133
|
"actor": "Acteurs : utilisateurs, populations, canaux",
|
|
@@ -171,7 +172,8 @@
|
|
|
171
172
|
"ec": "{n} External Credentials",
|
|
172
173
|
"rest": "{n} services Apex REST",
|
|
173
174
|
"apps2": "{n} applications connectées",
|
|
174
|
-
"caption": "Architecture logique en couches"
|
|
175
|
+
"caption": "Architecture logique en couches",
|
|
176
|
+
"servicesTitle": "Services"
|
|
175
177
|
},
|
|
176
178
|
"context": {
|
|
177
179
|
"salesforce": "Organisation Salesforce",
|
|
@@ -362,5 +364,9 @@
|
|
|
362
364
|
"nc": "Named / External credentials",
|
|
363
365
|
"rest": "Services Apex REST",
|
|
364
366
|
"reports": "Rapports / tableaux de bord"
|
|
367
|
+
},
|
|
368
|
+
"figures": {
|
|
369
|
+
"roles": "Hiérarchie des rôles",
|
|
370
|
+
"integrations": "Carte des intégrations"
|
|
365
371
|
}
|
|
366
372
|
}
|