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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -5
- package/package.json +1 -1
- package/skills/tadforce/references/questions.md +8 -9
- package/skills/tadforce/references/workflow.md +13 -7
- 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 +399 -135
- 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/agents.js +35 -0
- 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/org.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
}
|
package/src/commands/spec.js
CHANGED
|
@@ -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) ?
|
|
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
|
-
|
|
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'))
|
|
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)) {
|
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 } = {}) {
|
|
@@ -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
|
+
}
|
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}`);
|