tadforce 0.3.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.
@@ -124,28 +124,92 @@ export async function retrieve(args) {
124
124
  const size = Number(args.batchSize || 12);
125
125
  const outDir = ctx.p(ctx.cfg.source.dir);
126
126
  fs.mkdirSync(outDir, { recursive: true });
127
+ // the CLI refuses to retrieve when a package directory of the project is missing
128
+ for (const d of (exists(path.join(ctx.project, 'sfdx-project.json')) ? JSON.parse(readText(path.join(ctx.project, 'sfdx-project.json'))).packageDirectories || [] : [])) fs.mkdirSync(path.join(ctx.project, d.path), { recursive: true });
127
129
  log.banner('retrieve');
128
130
  const failed = [];
129
- const runBatch = async (batch, label) => {
131
+ const nameOf = (b) => /<name>([^<]+)<\/name>/.exec(b)[1];
132
+ const typeXml = (name, members) => ` <types>\n${members.map((m) => ` <members>${m}</members>`).join('\n')}\n <name>${name}</name>\n </types>`;
133
+ const runBatch = async (batch, label, minutes) => {
130
134
  const p = ctx.p('.snapshot', 'batch.xml');
131
135
  writeText(p, `<?xml version="1.0" encoding="UTF-8"?>\n<Package xmlns="http://soap.sforce.com/2006/04/metadata">\n${batch.join('\n')}\n <version>${version}</version>\n</Package>\n`);
132
- await sfJson(['project', 'retrieve', 'start', '--manifest', p, '--target-org', alias, '--output-dir', outDir, '--wait', String(args.wait || 60), '--ignore-conflicts'], { timeoutMs: 3600000, cwd: ctx.project });
133
- log.ok(label);
136
+ await sfJson(['project', 'retrieve', 'start', '--manifest', p, '--target-org', alias, '--output-dir', outDir, '--wait', String(minutes), '--ignore-conflicts'], { timeoutMs: (minutes + 2) * 60000, cwd: ctx.project });
137
+ if (label) log.ok(label);
134
138
  };
135
- for (let i = 0; i < types.length; i += size) {
136
- const batch = types.slice(i, i + size);
137
- const names = batch.map((b) => /<name>([^<]+)<\/name>/.exec(b)[1]);
139
+ // the CLI stopped waiting (« The client has timed out »): the job was too big for one retrieve
140
+ const timedOut = (e) => /timed out|MetadataTransferError/i.test(`${e.message} ${e.sf && e.sf.name}`);
141
+ // an error about the project itself fails every type the same way: stop at once, do not retry type by type
142
+ const projectError = (e) => /MissingPackageDirectory|InvalidProjectWorkspace|specified in sfdx-project\.json, does not exist|sfdx-project\.json/i.test(`${e.message} ${e.sf && e.sf.name}`);
143
+ // types the local Salesforce CLI does not know fail at once and never succeed: dropped, not retried
144
+ const unknownType = (e) => (/registry for id '([^']+)'|Missing metadata type definition[^']*'([^']+)'/.exec(e.message) || []).slice(1).find(Boolean);
145
+ // The types the document is built on come first, one by one, with up to 60 minutes each (--wait can only
146
+ // lengthen it). Too big for one retrieve (huge org, 39 MB / 10,000 files limit): its components are listed
147
+ // and retrieved by chunks of 100. The other types go by batches of 12 (30 minutes), then one by one (10 minutes).
148
+ const CORE = ['ApexClass', 'ApexTrigger', 'CustomObject', 'Flow', 'LightningComponentBundle', 'AuraDefinitionBundle', 'Profile', 'PermissionSet', 'PermissionSetGroup', 'FlexiPage', 'CustomApplication', 'CustomMetadata', 'OmniScript', 'OmniIntegrationProcedure', 'OmniDataTransform', 'OmniUiCard'];
149
+ // --core-wait shortens the first try (tests, impatient runs); the chunks always get the full 60 minutes
150
+ if (args.coreWait !== undefined && !/^\d+$/.test(String(args.coreWait))) throw new Error('--core-wait takes a number of minutes (0 or more).');
151
+ const coreWait = args.coreWait !== undefined ? Number(args.coreWait) : 60;
152
+ const chunkWait = 60;
153
+ const restWait = Math.min(Number(args.wait || 30), 30);
154
+ const byChunks = async (b, step) => {
155
+ const name = nameOf(b);
156
+ let listed;
157
+ try { listed = await sfJson(['org', 'list', 'metadata', '--metadata-type', name, '--target-org', alias], { timeoutMs: 600000 }); } catch (e) {
158
+ // a product this org does not have (OmniStudio, …): nothing to retrieve, as `*` would say
159
+ if (/INVALID_TYPE|Cannot use/i.test(e.message)) { log.info(`${step} ${name}: not used in this org`); return; }
160
+ throw e;
161
+ }
162
+ const explicit = [...b.matchAll(/<members>([^<]+)<\/members>/g)].map((m) => m[1]).filter((m) => m !== '*');
163
+ // the same components as `*`: everything but what installed packages bring (standard__ apps, force__ groups stay)
164
+ const own = (Array.isArray(listed) ? listed : []).filter((x) => !/^(installed|deprecated)/i.test(x.manageableState || ''));
165
+ const members = [...new Set([...own.map((x) => x.fullName), ...explicit])].sort();
166
+ if (!members.length) {
167
+ log.info(`${step} ${name}: nothing listed — one more try with the full wait`);
168
+ return runBatch([b], `${step} ${name}`, chunkWait);
169
+ }
170
+ log.info(`${step} ${name}: did not finish in ${coreWait} min — ${members.length} component(s), retrieved by chunks of 100`);
171
+ for (let i = 0; i < members.length; i += 100) await runBatch([typeXml(name, members.slice(i, i + 100))], `${step} ${name} ${Math.min(i + 100, members.length)}/${members.length}`, chunkWait);
172
+ };
173
+ const core = types.filter((b) => CORE.includes(nameOf(b)));
174
+ const rest = types.filter((b) => !CORE.includes(nameOf(b)));
175
+ const total = core.length + Math.ceil(rest.length / size);
176
+ let n = 0;
177
+ for (const b of core) {
178
+ const step = `[${++n}/${total}]`;
138
179
  try {
139
- await runBatch(batch, `${names.join(', ')}`);
180
+ try { await runBatch([b], `${step} ${nameOf(b)}`, coreWait); } catch (e) { if (!timedOut(e)) throw e; await byChunks(b, step); }
140
181
  } catch (e) {
141
- log.warn(`Batch failed (${e.message.slice(0, 160)}) — retrying type by type`);
142
- for (const b of batch) {
143
- const n = /<name>([^<]+)<\/name>/.exec(b)[1];
144
- try { await runBatch([b], n); } catch (e2) { failed.push({ type: n, error: e2.message.slice(0, 300) }); log.warn(`${n}: skipped (${e2.message.slice(0, 120)})`); }
182
+ if (projectError(e)) throw new Error(`The Salesforce DX project cannot be used (${e.message.slice(0, 200)}). Fix sfdx-project.json or its package directories, then retrieve again.`);
183
+ const why = unknownType(e) ? 'not supported by this Salesforce CLI version' : e.message.slice(0, 300);
184
+ failed.push({ type: nameOf(b), error: why, core: true });
185
+ log.warn(`${step} ${nameOf(b)}: NOT retrieved (${why.slice(0, 120)}) — the document needs it`);
186
+ }
187
+ }
188
+ for (let i = 0; i < rest.length; i += size) {
189
+ let batch = rest.slice(i, i + size);
190
+ const step = `[${++n}/${total}]`;
191
+ for (let tries = 0; batch.length && tries < 3; tries++) {
192
+ try { await runBatch(batch, `${step} ${batch.map(nameOf).join(', ')}`, restWait); batch = []; } catch (e) {
193
+ if (projectError(e)) throw new Error(`The Salesforce DX project cannot be used (${e.message.slice(0, 200)}). Fix sfdx-project.json or its package directories, then retrieve again.`);
194
+ const bad = unknownType(e);
195
+ if (bad && batch.some((b) => nameOf(b) === bad)) {
196
+ failed.push({ type: bad, error: 'not supported by this Salesforce CLI version' });
197
+ log.warn(`${bad}: skipped (not supported by this Salesforce CLI version)`);
198
+ batch = batch.filter((b) => nameOf(b) !== bad);
199
+ continue;
200
+ }
201
+ log.warn(`${step} batch failed (${e.message.slice(0, 160)}) — retrying type by type`);
202
+ for (const b of batch) {
203
+ try { await runBatch([b], `${step} ${nameOf(b)}`, Math.min(10, restWait)); } catch (e2) { failed.push({ type: nameOf(b), error: e2.message.slice(0, 300) }); log.warn(`${nameOf(b)}: skipped (${e2.message.slice(0, 120)})`); }
204
+ }
205
+ batch = [];
145
206
  }
146
207
  }
147
208
  }
148
209
  writeJson(ctx.p('.snapshot', 'retrieve-report.json'), { failed });
210
+ // a type the document is built on is missing: not a ready snapshot
211
+ const lost = failed.filter((x) => x.core).map((x) => x.type);
212
+ if (lost.length) { log.fail(`Not retrieved, and the document needs them: ${lost.join(', ')}. Run the retrieve again (tadforce doctor --refresh).`); return 1; }
149
213
  log.ok(`Snapshot ready in ${path.relative(ctx.project, outDir)}${failed.length ? ` — ${failed.length} type(s) could not be retrieved (see .snapshot/retrieve-report.json)` : ''}.`);
150
214
  return 0;
151
215
  }
@@ -2,8 +2,8 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import YAML from 'yaml';
5
- import { exists, readText, readYaml } from '../util/fsx.js';
6
- import { loadWorkspace } from '../util/config.js';
5
+ import { exists, readText, readYaml, walk, toPosix } from '../util/fsx.js';
6
+ import { loadWorkspace, sourceFlags } from '../util/config.js';
7
7
  import { TEMPLATES } from '../util/paths.js';
8
8
  import { loadFacts } from '../facts/index.js';
9
9
  import { resolveConditionals, get } from '../build/directives.js';
@@ -33,13 +33,19 @@ export function specWithFacts(ctx, id) {
33
33
  const out = { id, ...s, factsExcerpt: {} };
34
34
  for (const p of s.facts || []) {
35
35
  if (p.startsWith('knowledge/')) {
36
+ // every file, sub-folders included (knowledge/inputs/specs/SFD.pdf), README excepted
36
37
  const dir = ctx.p(p);
37
- out.factsExcerpt[p] = exists(dir) ? fs.readdirSync(dir) : [];
38
+ out.factsExcerpt[p] = exists(dir) ? walk(dir).map((x) => toPosix(path.relative(dir, x))).filter((x) => x !== 'README.md') : [];
38
39
  continue;
39
40
  }
40
41
  const v = get(facts, p) ?? get(facts.counts || {}, p);
41
42
  if (v !== undefined) out.factsExcerpt[p] = excerpt(v);
42
43
  }
44
+ // Functional specifications given by the project: evidence P2/P3 for the chapters that explain the why.
45
+ const projectSpecs = (ctx.cfg.inputs && ctx.cfg.inputs.specs) || [];
46
+ if (projectSpecs.length && /^(summary|purpose|context|functional|general)\./.test(id)) {
47
+ out.specifications = { read: projectSpecs.map((p) => toPosix(path.join('tadforce', p))), how: 'Read them before writing: they explain the purpose, the processes and the vocabulary. Cite them (« according to the specifications (DR-nn) ») — the metadata still decides what is actually built.' };
48
+ }
43
49
  if (s.modules) {
44
50
  const refs = s.modules.includes('all detected') ? (facts.modules && facts.modules.references) || [] : s.modules.filter((m) => (facts.modules && facts.modules.references || []).includes(m) || s.modules.length === 1);
45
51
  out.readModules = refs.map((r) => `skills/tadforce/references/modules/${r}.md`);
@@ -57,18 +63,25 @@ export async function spec(args) {
57
63
  return 0;
58
64
  }
59
65
 
60
- /** First section still to write (in reading order), among the sections active for this org. */
66
+ // The order that works: the facts-heavy chapters first, the summary last (it is written from all the others).
67
+ const ORDER = { tad: ['00', '06', '07', '08', '09', '10', '11', '12', '13', '05', '04', '03', '14', '15', '99', '02', '01'], axes: ['00', '03', '04', '05', '02', '99', '01'] };
68
+ export function writingOrder(doc, files) {
69
+ const rank = (f) => { const i = (ORDER[doc] || []).indexOf(f.slice(0, 2)); return i < 0 ? 50 : i; };
70
+ return [...files].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
71
+ }
72
+
73
+ /** First section still to write (in the recommended order), among the sections active for this org. */
61
74
  export async function next(args) {
62
75
  const ctx = loadWorkspace(args.cwd || process.cwd());
63
76
  const facts = loadFacts(ctx.ws);
64
77
  if (!facts) { log.fail('Run `tadforce facts` first.'); return 1; }
65
- const scope = { modules: facts.modules, counts: facts.counts, org: facts.org, cfg: ctx.cfg, facts, glossary: ctx.glossary() };
78
+ const scope = { modules: facts.modules, counts: facts.counts, org: facts.org, cfg: ctx.cfg, facts, glossary: ctx.glossary(), source: sourceFlags(ctx.cfg) };
66
79
  const docs = !args.doc || args.doc === 'all' ? DOCS : [args.doc];
67
80
  let remaining = 0, first = null;
68
81
  for (const doc of docs) {
69
82
  const dir = ctx.p('src', doc);
70
83
  if (!exists(dir)) continue;
71
- for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md')).sort()) {
84
+ for (const f of writingOrder(doc, fs.readdirSync(dir).filter((x) => x.endsWith('.md')))) {
72
85
  const raw = readText(path.join(dir, f));
73
86
  const active = resolveConditionals(raw, scope);
74
87
  for (const m of active.matchAll(/<!--\s*tad:pending\s+([\w.-]+)\s*-->/g)) {
@@ -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 } = {}) {
@@ -0,0 +1,35 @@
1
+ // AI agents TADForce can hand over to once `doctor` is green: the agent writes the document.
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ const RUN = '/tadforce';
5
+ const PROMPT = 'Use the tadforce skill: the prerequisites are green (tadforce doctor). Write every section of the Technical Architecture Document and the Improvement Axes, then build and validate them.';
6
+
7
+ /** Command-line agents, in order of preference: the argument list that starts an interactive session on the task. */
8
+ export const AGENTS = [
9
+ { id: 'claude', name: 'Claude Code', bin: 'claude', args: [RUN] },
10
+ { id: 'codex', name: 'Codex', bin: 'codex', args: [PROMPT] },
11
+ { id: 'gemini', name: 'Gemini CLI', bin: 'gemini', args: ['-i', PROMPT] },
12
+ { id: 'cursor', name: 'Cursor Agent', bin: 'cursor-agent', args: [PROMPT] },
13
+ ];
14
+
15
+ const onPath = (bin) => spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf8' }).status === 0;
16
+
17
+ /** Agents installed on this machine. */
18
+ export function installedAgents() {
19
+ return AGENTS.filter((a) => onPath(a.bin));
20
+ }
21
+
22
+ /** True when TADForce already runs inside an agent session: never start another agent from there. */
23
+ export function insideAgent() {
24
+ const e = process.env;
25
+ return !!(e.CLAUDECODE || e.CLAUDE_CODE_ENTRYPOINT || e.CODEX_SANDBOX || e.CODEX_HOME && e.CODEX_SESSION || e.GEMINI_CLI || e.CURSOR_AGENT || e.TADFORCE_NO_LAUNCH);
26
+ }
27
+
28
+ /** Start the agent in this terminal, in the project folder. Returns its exit code. */
29
+ export function launchAgent(agent, cwd) {
30
+ const quote = (a) => (/^[\w./-]+$/.test(a) ? a : `"${a.replace(/"/g, '\\"')}"`);
31
+ const r = process.platform === 'win32'
32
+ ? spawnSync([agent.bin, ...agent.args].map(quote).join(' '), [], { cwd, stdio: 'inherit', shell: true })
33
+ : spawnSync(agent.bin, agent.args, { cwd, stdio: 'inherit' });
34
+ return r.status ?? 1;
35
+ }
@@ -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}`);