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.
@@ -61,20 +61,33 @@ function sectionLocations(lang) {
61
61
  return out;
62
62
  }
63
63
 
64
- /** Sections whose spec reads a facts area that changed. */
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
- for (const p of s.facts || []) {
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, why] of Object.entries(areas)) {
72
- if (p === 'counts' || fp === area || fp.startsWith(area + '.') || area.startsWith(fp + '.') || (p === 'modules' && area === 'modules')) reasons.push(...why);
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) hits.push({ id, ...(locations[id] || {}), reasons: [...new Set(reasons)].slice(0, 12) });
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
- return hits;
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. Archive what the current document was written from.
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
- const quiet = async (fn) => { if (!args.json) return fn(); const o = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = o; } };
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
- const sections = affectedSections(areas, specs, sectionLocations(templateLang(ctx2.cfg.language)));
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(' — ') || cfg.client.name;
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 purpose = cfg.language === 'fr'
137
- ? `Mise à jour : ${diff.added.length} composant(s) ajouté(s), ${diff.changed.length} modifié(s), ${diff.removed.length} supprimé(s)`
138
- : `Update: ${diff.added.length} component(s) added, ${diff.changed.length} changed, ${diff.removed.length} removed`;
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 each listed section from the new facts (keep its heading and anchor), then `tadforce build`' : 'tadforce build' };
143
- writeJson(ctx2.p('facts', 'update.json'), report);
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 (!total && !Object.keys(counts).length) { log.ok('Nothing changed since the last version. The document is up to date.'); return 0; }
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.settings.some((s) => /PlatformEncryption|EventSettings|FieldHistoryRetention/.test(s)) },
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) });
@@ -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
- return ALLOWED.some((p) => p.length === cmd.length && p.every((w, i) => w === cmd[i]));
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
- /** Run `sf ... --json` and return the parsed result (throws with the CLI message on failure). */
70
- export function sfJson(args, { timeoutMs = 600000, cwd } = {}) {
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
- return m ? m[1] : (r.stdout || '').trim() || null;
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 } = {}) {
@@ -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 init\` first.`);
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.
@@ -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
- if (/\bTODO\b|\bTBD\b|lorem ipsum/i.test(line)) add('error', 'todo', 'Placeholder text left in the document.', where);
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}`);
@@ -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
  })();
@@ -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
  }
@@ -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
  }