tadforce 0.0.0-stage → 0.3.0
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 +20 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +266 -2
- package/assets/logo-mark.svg +24 -0
- package/assets/logo.svg +31 -0
- package/bin/tadforce.js +4 -0
- package/commands/tadforce-review.md +8 -0
- package/commands/tadforce-update.md +6 -0
- package/commands/tadforce.md +10 -0
- package/package.json +60 -4
- package/skills/tadforce/SKILL.md +73 -0
- package/skills/tadforce/references/axes-rules.md +29 -0
- package/skills/tadforce/references/evidence-rules.md +40 -0
- package/skills/tadforce/references/exclusion-heuristics.md +40 -0
- package/skills/tadforce/references/figure-rules.md +42 -0
- package/skills/tadforce/references/markdown-conventions.md +67 -0
- package/skills/tadforce/references/modules/agentforce-ai.md +24 -0
- package/skills/tadforce/references/modules/analytics.md +19 -0
- package/skills/tadforce/references/modules/commerce.md +25 -0
- package/skills/tadforce/references/modules/data-cloud.md +23 -0
- package/skills/tadforce/references/modules/experience-cloud.md +24 -0
- package/skills/tadforce/references/modules/field-service.md +25 -0
- package/skills/tadforce/references/modules/industries.md +21 -0
- package/skills/tadforce/references/modules/integration-platforms.md +19 -0
- package/skills/tadforce/references/modules/marketing.md +24 -0
- package/skills/tadforce/references/modules/omnistudio.md +24 -0
- package/skills/tadforce/references/modules/platform-events.md +19 -0
- package/skills/tadforce/references/modules/revenue-cpq.md +25 -0
- package/skills/tadforce/references/modules/sales-cloud.md +26 -0
- package/skills/tadforce/references/modules/security-shield.md +18 -0
- package/skills/tadforce/references/modules/service-cloud.md +27 -0
- package/skills/tadforce/references/naming-external-systems.md +32 -0
- package/skills/tadforce/references/questions.md +59 -0
- package/skills/tadforce/references/salesforce-skills.md +27 -0
- package/skills/tadforce/references/workflow.md +95 -0
- package/skills/tadforce/references/writing-rules.md +47 -0
- package/skills/tadforce-review/SKILL.md +53 -0
- package/skills/tadforce-update/SKILL.md +48 -0
- package/src/build/brand.js +107 -0
- package/src/build/browser.js +70 -0
- package/src/build/directives.js +293 -0
- package/src/build/export.js +60 -0
- package/src/build/figures.js +119 -0
- package/src/build/mermaid.js +107 -0
- package/src/build/render.js +299 -0
- package/src/build/serve.js +59 -0
- package/src/cli.js +68 -0
- package/src/commands/docs.js +204 -0
- package/src/commands/doctor.js +370 -0
- package/src/commands/init.js +132 -0
- package/src/commands/org.js +151 -0
- package/src/commands/review.js +35 -0
- package/src/commands/spec.js +91 -0
- package/src/commands/update.js +154 -0
- package/src/facts/extractors/apex.js +111 -0
- package/src/facts/extractors/automation.js +85 -0
- package/src/facts/extractors/data.js +94 -0
- package/src/facts/extractors/integration.js +76 -0
- package/src/facts/extractors/misc.js +100 -0
- package/src/facts/extractors/security.js +92 -0
- package/src/facts/extractors/ui.js +120 -0
- package/src/facts/fingerprints.js +32 -0
- package/src/facts/heuristics/exclusions.js +143 -0
- package/src/facts/heuristics/modules.js +85 -0
- package/src/facts/heuristics/naming.js +31 -0
- package/src/facts/heuristics/signals.js +76 -0
- package/src/facts/index.js +197 -0
- package/src/facts/org.js +87 -0
- package/src/facts/xml.js +85 -0
- package/src/sf/sf.js +120 -0
- package/src/util/args.js +21 -0
- package/src/util/config.js +104 -0
- package/src/util/fsx.js +58 -0
- package/src/util/i18n.js +49 -0
- package/src/util/log.js +31 -0
- package/src/util/paths.js +11 -0
- package/src/validate/index.js +126 -0
- package/templates/axes/en/00-document-control.md +3 -0
- package/templates/axes/en/01-summary.md +7 -0
- package/templates/axes/en/02-method.md +9 -0
- package/templates/axes/en/03-findings.md +19 -0
- package/templates/axes/en/04-register.md +3 -0
- package/templates/axes/en/05-roadmap.md +3 -0
- package/templates/axes/en/99-appendix.md +5 -0
- package/templates/axes/fr/00-document-control.md +3 -0
- package/templates/axes/fr/01-summary.md +7 -0
- package/templates/axes/fr/02-method.md +9 -0
- package/templates/axes/fr/03-findings.md +19 -0
- package/templates/axes/fr/04-register.md +3 -0
- package/templates/axes/fr/05-roadmap.md +3 -0
- package/templates/axes/fr/99-appendix.md +5 -0
- package/templates/html/cover.html +12 -0
- package/templates/html/editor.js +502 -0
- package/templates/html/style.css +255 -0
- package/templates/html/template.html +88 -0
- package/templates/i18n/en.json +366 -0
- package/templates/i18n/fr.json +366 -0
- package/templates/manifest/package-tad.xml +543 -0
- package/templates/specs/sections.yml +494 -0
- package/templates/tad/en/00-document-control.md +7 -0
- package/templates/tad/en/01-summary.md +17 -0
- package/templates/tad/en/02-purpose.md +35 -0
- package/templates/tad/en/03-context.md +17 -0
- package/templates/tad/en/04-general.md +29 -0
- package/templates/tad/en/05-functional.md +7 -0
- package/templates/tad/en/06-data.md +71 -0
- package/templates/tad/en/07-application.md +49 -0
- package/templates/tad/en/08-ui.md +41 -0
- package/templates/tad/en/09-integrations.md +53 -0
- package/templates/tad/en/10-security.md +59 -0
- package/templates/tad/en/11-reporting.md +13 -0
- package/templates/tad/en/12-operations.md +25 -0
- package/templates/tad/en/13-nfr.md +9 -0
- package/templates/tad/en/14-decisions.md +3 -0
- package/templates/tad/en/15-outlook.md +3 -0
- package/templates/tad/en/99-appendices.md +119 -0
- package/templates/tad/fr/00-document-control.md +7 -0
- package/templates/tad/fr/01-summary.md +17 -0
- package/templates/tad/fr/02-purpose.md +35 -0
- package/templates/tad/fr/03-context.md +17 -0
- package/templates/tad/fr/04-general.md +29 -0
- package/templates/tad/fr/05-functional.md +7 -0
- package/templates/tad/fr/06-data.md +71 -0
- package/templates/tad/fr/07-application.md +49 -0
- package/templates/tad/fr/08-ui.md +41 -0
- package/templates/tad/fr/09-integrations.md +53 -0
- package/templates/tad/fr/10-security.md +59 -0
- package/templates/tad/fr/11-reporting.md +13 -0
- package/templates/tad/fr/12-operations.md +25 -0
- package/templates/tad/fr/13-nfr.md +9 -0
- package/templates/tad/fr/14-decisions.md +3 -0
- package/templates/tad/fr/15-outlook.md +3 -0
- package/templates/tad/fr/99-appendices.md +119 -0
- package/templates/vendor/LICENSE-html-docx-js.txt +21 -0
- package/templates/vendor/LICENSE-html2canvas.txt +22 -0
- package/templates/vendor/LICENSE-mermaid.txt +21 -0
- package/templates/vendor/html-docx.js +13216 -0
- package/templates/vendor/html2canvas.min.js +20 -0
- package/templates/vendor/mermaid.min.js +2314 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline/promises';
|
|
4
|
+
import { exists, writeText, writeJson, copyDir, readJson } from '../util/fsx.js';
|
|
5
|
+
import { defaultConfig, findWorkspace } from '../util/config.js';
|
|
6
|
+
import { WORKSPACE_DIR, CONFIG_FILE, SKILLS } from '../util/paths.js';
|
|
7
|
+
import { LANGUAGES, normalizeLang } from '../util/i18n.js';
|
|
8
|
+
import { log } from '../util/log.js';
|
|
9
|
+
import { run as doctor } from './doctor.js';
|
|
10
|
+
|
|
11
|
+
const GLOSSARY = `# TADForce glossary — the client's vocabulary is law.
|
|
12
|
+
# terms: terminology imposed by the client. \`tadforce validate\` fails when an "avoid" form appears.
|
|
13
|
+
# systems: names of the systems Salesforce talks to. Never guessed: confirmed by a human.
|
|
14
|
+
# "match" lists named credentials, remote sites, auth providers… or hosts (api.example.com).
|
|
15
|
+
# actors: user populations shown on the context view.
|
|
16
|
+
terms: []
|
|
17
|
+
# - use: "API Gateway"
|
|
18
|
+
# avoid: ["API passerelle"]
|
|
19
|
+
# definition: "Single entry point exposing the bank's internal services."
|
|
20
|
+
systems: []
|
|
21
|
+
# - id: core-banking
|
|
22
|
+
# name: "Core banking"
|
|
23
|
+
# description: "System of record for accounts and contracts"
|
|
24
|
+
# match: ["Core_Banking_NC", "api.core.example.com"]
|
|
25
|
+
actors: []
|
|
26
|
+
# - name: "Branch advisors"
|
|
27
|
+
# channel: "Branch console"
|
|
28
|
+
# allow: technical terms quoted in code font on purpose, unknown to the metadata (API payload fields, external IDs…)
|
|
29
|
+
allow: []
|
|
30
|
+
# products: Salesforce products used without trace in the metadata (reference: a file of skills/tadforce/references/modules/)
|
|
31
|
+
products: []
|
|
32
|
+
# - id: marketingCloud
|
|
33
|
+
# name: "Marketing Cloud Engagement"
|
|
34
|
+
# reference: marketing
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const EXCLUSIONS = `# TADForce exclusions — what the document must NOT describe.
|
|
38
|
+
# exclude: components outside the production architecture (POC, test, demo, unused). "*" wildcards allowed.
|
|
39
|
+
# type (optional): apex, trigger, lwc, aura, flow, flexipage, app, object, permissionSet, omniScript, report…
|
|
40
|
+
# features: decommissioned features. Their components are excluded and their keywords are forbidden in the text.
|
|
41
|
+
exclude: []
|
|
42
|
+
# - name: "*Demo*"
|
|
43
|
+
# reason: "Demonstration components"
|
|
44
|
+
# - name: legacyScoring
|
|
45
|
+
# type: lwc
|
|
46
|
+
# reason: "Replaced by the scoring engine; still deployed, not used"
|
|
47
|
+
features: []
|
|
48
|
+
# - name: "Search by national ID"
|
|
49
|
+
# note: "UI removed; backend kept until further notice. Do not document."
|
|
50
|
+
# components: [idSearch, IdSearchController]
|
|
51
|
+
# keywords: ["national ID search"]
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
/** Agent folders that receive the skills. Others are added when their folder already exists. */
|
|
55
|
+
const AGENT_DIRS = ['.claude', '.agents'];
|
|
56
|
+
const OPTIONAL_AGENT_DIRS = ['.codex', '.cursor', '.windsurf', '.gemini', '.github', '.kiro', '.roo', '.opencode', '.continue'];
|
|
57
|
+
|
|
58
|
+
export function installSkills(project, agents) {
|
|
59
|
+
const targets = agents && agents.length ? agents.map((a) => (a.startsWith('.') ? a : '.' + a)) : [...AGENT_DIRS, ...OPTIONAL_AGENT_DIRS.filter((d) => exists(path.join(project, d)))];
|
|
60
|
+
const done = [];
|
|
61
|
+
for (const dir of [...new Set(targets)]) {
|
|
62
|
+
for (const skill of fs.readdirSync(SKILLS)) {
|
|
63
|
+
copyDir(path.join(SKILLS, skill), path.join(project, dir, 'skills', skill), { overwrite: true });
|
|
64
|
+
}
|
|
65
|
+
done.push(path.join(dir, 'skills'));
|
|
66
|
+
}
|
|
67
|
+
return done;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Minimal SFDX project, knowledge files and .gitignore: everything a workspace needs, existing files kept. */
|
|
71
|
+
export function ensureWorkspace(project, cfg) {
|
|
72
|
+
const ws = findWorkspace(project) || path.join(project, WORKSPACE_DIR);
|
|
73
|
+
if (!exists(path.join(project, 'sfdx-project.json'))) {
|
|
74
|
+
writeJson(path.join(project, 'sfdx-project.json'), { packageDirectories: [{ path: 'force-app', default: true }], name: path.basename(project), namespace: '', sourceApiVersion: '62.0' });
|
|
75
|
+
fs.mkdirSync(path.join(project, 'force-app', 'main', 'default'), { recursive: true });
|
|
76
|
+
log.ok('Created a minimal SFDX project (sfdx-project.json, force-app/).');
|
|
77
|
+
}
|
|
78
|
+
writeJson(path.join(ws, CONFIG_FILE), cfg);
|
|
79
|
+
if (!exists(path.join(ws, 'knowledge', 'glossary.yml'))) writeText(path.join(ws, 'knowledge', 'glossary.yml'), GLOSSARY);
|
|
80
|
+
if (!exists(path.join(ws, 'knowledge', 'exclusions.yml'))) writeText(path.join(ws, 'knowledge', 'exclusions.yml'), EXCLUSIONS);
|
|
81
|
+
if (!exists(path.join(ws, 'knowledge', 'inputs', 'README.md'))) writeText(path.join(ws, 'knowledge', 'inputs', 'README.md'), '# Inputs\n\nDrop here what the metadata cannot tell: functional specifications (`specs/`), previous architecture documents, a Code Analyzer report (`code-analyzer-results.json`), decisions. They are read by the agent, never published as such.\n');
|
|
82
|
+
if (!exists(path.join(ws, '.gitignore'))) writeText(path.join(ws, '.gitignore'), '.snapshot/\n.cache/\nout/*_comments_*.html\n');
|
|
83
|
+
return ws;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function ask(rl, q, def) {
|
|
87
|
+
if (!rl) return def;
|
|
88
|
+
const a = (await rl.question(` ${q}${def ? ` [${def}]` : ''}: `)).trim();
|
|
89
|
+
return a || def;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function run(args) {
|
|
93
|
+
const project = path.resolve(args.cwd || process.cwd());
|
|
94
|
+
log.banner('init');
|
|
95
|
+
const existing = findWorkspace(project);
|
|
96
|
+
const ws = existing || path.join(project, WORKSPACE_DIR);
|
|
97
|
+
const interactive = process.stdin.isTTY && !args.yes;
|
|
98
|
+
const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
99
|
+
try {
|
|
100
|
+
const prev = existing ? readJson(path.join(ws, CONFIG_FILE)) : {};
|
|
101
|
+
const base = defaultConfig(prev);
|
|
102
|
+
if (interactive) log.step('A few facts only you can confirm (Enter keeps the default):');
|
|
103
|
+
const language = args.lang || (await ask(rl, `Document language (${LANGUAGES.join('/')} built in, or any code: es, de, it, pt, ar…)`, base.language));
|
|
104
|
+
const clientName = args.client || (await ask(rl, 'Client name', prev.client?.name || base.client.name));
|
|
105
|
+
const short = args.short || (await ask(rl, 'Client short name (used in file names)', prev.client?.short || clientName.replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toUpperCase()));
|
|
106
|
+
const integrator = args.integrator || (await ask(rl, 'Integrator (blank if none)', base.integrator.name || ''));
|
|
107
|
+
const author = args.author || (await ask(rl, 'Author of the document (blank: not shown)', base.document.author || ''));
|
|
108
|
+
const title = args.title || (await ask(rl, 'Document title', base.document.title));
|
|
109
|
+
const alias = args.org || (await ask(rl, 'Salesforce org alias (blank: decide later)', base.org.alias || ''));
|
|
110
|
+
const cfg = defaultConfig({
|
|
111
|
+
...prev,
|
|
112
|
+
language: normalizeLang(language),
|
|
113
|
+
client: { ...base.client, name: clientName, short },
|
|
114
|
+
integrator: { ...base.integrator, name: integrator || null },
|
|
115
|
+
document: { ...base.document, author: author || null, title, reference: prev.document?.reference || `TAD-${short}-CRM`, environment: args.environment || base.document.environment, status: language === 'fr' && !prev.document?.status ? 'Pour revue' : base.document.status, classification: language === 'fr' && !prev.document?.classification ? 'Confidentiel' : base.document.classification },
|
|
116
|
+
org: { alias: alias || null },
|
|
117
|
+
});
|
|
118
|
+
ensureWorkspace(project, cfg);
|
|
119
|
+
log.ok(`Workspace ready: ${path.relative(project, ws) || '.'}/`);
|
|
120
|
+
if (args.skills !== false) {
|
|
121
|
+
const dirs = installSkills(project, args.agents ? String(args.agents).split(',') : null);
|
|
122
|
+
log.ok(`Skills installed in ${dirs.join(', ')} → run /tadforce in your agent.`);
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
if (rl) rl.close();
|
|
126
|
+
}
|
|
127
|
+
console.log('');
|
|
128
|
+
await doctor({ cwd: project });
|
|
129
|
+
console.log('');
|
|
130
|
+
log.human('open your AI agent in this folder and type /tadforce — or run `npx tadforce doctor` to finish the setup here.');
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// auth (browser login), detect (startup facts for the questions), manifest and retrieve.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { exists, readText, writeText, writeJson } from '../util/fsx.js';
|
|
5
|
+
import { loadWorkspace } from '../util/config.js';
|
|
6
|
+
import { TEMPLATES } from '../util/paths.js';
|
|
7
|
+
import { sfJson, sfInteractive, soql } from '../sf/sf.js';
|
|
8
|
+
import { log } from '../util/log.js';
|
|
9
|
+
|
|
10
|
+
function requireAlias(ctx, args) {
|
|
11
|
+
const alias = args.org || ctx.cfg.org.alias;
|
|
12
|
+
if (!alias) throw new Error('No target org. Pass --org <alias> or run `tadforce init --org <alias>`.');
|
|
13
|
+
return alias;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function auth(args) {
|
|
17
|
+
const ctx = loadWorkspace(args.cwd || process.cwd());
|
|
18
|
+
const alias = args.alias || args.org || ctx.cfg.org.alias || 'tadforce-org';
|
|
19
|
+
const instance = args.instanceUrl || (args.sandbox ? 'https://test.salesforce.com' : null);
|
|
20
|
+
log.banner('auth');
|
|
21
|
+
log.human(`a browser window opens: log in to the org, then click « Allow ». Alias: ${alias}`);
|
|
22
|
+
const ok = sfInteractive(['org', 'login', 'web', '--alias', alias, ...(instance ? ['--instance-url', instance] : [])]);
|
|
23
|
+
if (!ok) { log.fail('Login did not complete.'); return 1; }
|
|
24
|
+
ctx.save({ ...ctx.cfg, org: { ...ctx.cfg.org, alias } });
|
|
25
|
+
log.ok(`Org authorized and saved in the workspace as "${alias}".`);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Facts to propose answers for the startup questions (language, client, integrator, environment). */
|
|
30
|
+
export async function detect(args) {
|
|
31
|
+
const ctx = loadWorkspace(args.cwd || process.cwd());
|
|
32
|
+
const alias = requireAlias(ctx, args);
|
|
33
|
+
const display = await sfJson(['org', 'display', '--target-org', alias]);
|
|
34
|
+
const org = ((await soql('SELECT Name, IsSandbox, OrganizationType, LanguageLocaleKey, DefaultLocaleSidKey, InstanceName FROM Organization', alias)).records || [])[0] || {};
|
|
35
|
+
const username = display.username || '';
|
|
36
|
+
const domain = (/@([^@]+)$/.exec(username) || [])[1] || null;
|
|
37
|
+
const result = {
|
|
38
|
+
alias,
|
|
39
|
+
username,
|
|
40
|
+
instanceUrl: display.instanceUrl,
|
|
41
|
+
apiVersion: display.apiVersion,
|
|
42
|
+
organization: { name: org.Name, sandbox: org.IsSandbox, edition: org.OrganizationType, language: org.LanguageLocaleKey, locale: org.DefaultLocaleSidKey, instance: org.InstanceName },
|
|
43
|
+
proposals: {
|
|
44
|
+
language: /^fr/i.test(org.LanguageLocaleKey || '') ? 'fr' : 'en',
|
|
45
|
+
client: org.Name,
|
|
46
|
+
environment: org.IsSandbox ? 'Sandbox' : 'Production',
|
|
47
|
+
integratorFromUserDomain: domain && !/salesforce\.com$|force\.com$/.test(domain) ? domain.split('.').slice(-2, -1)[0] : null,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
console.log(JSON.stringify(result, null, 2));
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const FOLDERED = { Report: 'ReportFolder', Dashboard: 'DashboardFolder', EmailTemplate: 'EmailFolder', Document: 'DocumentFolder' };
|
|
55
|
+
|
|
56
|
+
/** Build tadforce/.snapshot/package.xml: curated wildcard manifest filtered on what the org supports. */
|
|
57
|
+
export async function manifest(args) {
|
|
58
|
+
const ctx = loadWorkspace(args.cwd || process.cwd());
|
|
59
|
+
const alias = requireAlias(ctx, args);
|
|
60
|
+
const mode = args.mode || ctx.cfg.manifest.mode || 'curated';
|
|
61
|
+
const out = ctx.p('.snapshot', 'package.xml');
|
|
62
|
+
log.banner('manifest');
|
|
63
|
+
if (mode === 'full') {
|
|
64
|
+
log.step('Listing every metadata component of the org (packages excluded)…');
|
|
65
|
+
await sfJson(['project', 'generate', 'manifest', '--from-org', alias, '--output-dir', ctx.p('.snapshot'), '--name', 'package.xml', ...(ctx.cfg.manifest.includePackages ? ['--include-packages', 'managed,unlocked'] : [])], { timeoutMs: 1800000 });
|
|
66
|
+
log.ok(`Manifest written: ${path.relative(ctx.project, out)}`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
const tpl = readText(path.join(TEMPLATES, 'manifest', 'package-tad.xml'));
|
|
70
|
+
const types = [...tpl.matchAll(/<types>([\s\S]*?)<\/types>/g)].map((m) => ({ name: /<name>([^<]+)<\/name>/.exec(m[1])[1], members: [...m[1].matchAll(/<members>([^<]+)<\/members>/g)].map((x) => x[1]) }));
|
|
71
|
+
const version = /<version>([^<]+)<\/version>/.exec(tpl)[1];
|
|
72
|
+
log.step('Asking the org which metadata types it supports…');
|
|
73
|
+
const described = await sfJson(['org', 'list', 'metadata-types', '--target-org', alias], { timeoutMs: 300000 });
|
|
74
|
+
const supported = new Set();
|
|
75
|
+
for (const o of described.metadataObjects || []) { supported.add(o.xmlName); for (const ch of o.childXmlNames || []) supported.add(ch); }
|
|
76
|
+
const kept = [], dropped = [];
|
|
77
|
+
for (const t of types) (supported.has(t.name) || t.name === 'CustomObject' ? kept : dropped).push(t);
|
|
78
|
+
|
|
79
|
+
// Foldered types: `*` does not reach inside folders, list each folder's content explicitly.
|
|
80
|
+
for (const t of kept.filter((x) => FOLDERED[x.name])) {
|
|
81
|
+
try {
|
|
82
|
+
const folders = await sfJson(['org', 'list', 'metadata', '--metadata-type', FOLDERED[t.name], '--target-org', alias]);
|
|
83
|
+
const names = [];
|
|
84
|
+
for (const fo of (Array.isArray(folders) ? folders : []).filter((x) => !x.namespacePrefix)) {
|
|
85
|
+
names.push(fo.fullName);
|
|
86
|
+
const items = await sfJson(['org', 'list', 'metadata', '--metadata-type', t.name, '--folder', fo.fullName, '--target-org', alias]).catch(() => []);
|
|
87
|
+
for (const it of Array.isArray(items) ? items : []) if (!it.namespacePrefix) names.push(it.fullName);
|
|
88
|
+
}
|
|
89
|
+
if (t.name === 'Report' || t.name === 'Dashboard') {
|
|
90
|
+
const unfiled = await sfJson(['org', 'list', 'metadata', '--metadata-type', t.name, '--folder', 'unfiled$public', '--target-org', alias]).catch(() => []);
|
|
91
|
+
for (const it of Array.isArray(unfiled) ? unfiled : []) names.push(it.fullName);
|
|
92
|
+
}
|
|
93
|
+
t.members = [...new Set(names)].sort();
|
|
94
|
+
log.info(`${t.name}: ${t.members.length} member(s) in folders`);
|
|
95
|
+
} catch (e) { log.warn(`${t.name}: folder listing failed (${e.message.slice(0, 120)})`); }
|
|
96
|
+
}
|
|
97
|
+
// Standard objects: `*` covers custom objects only; add the standard objects the org exposes.
|
|
98
|
+
try {
|
|
99
|
+
const objs = await sfJson(['org', 'list', 'metadata', '--metadata-type', 'CustomObject', '--target-org', alias], { timeoutMs: 300000 });
|
|
100
|
+
const co = kept.find((x) => x.name === 'CustomObject');
|
|
101
|
+
const std = (Array.isArray(objs) ? objs : []).filter((o) => !o.namespacePrefix && !/__/.test(o.fullName)).map((o) => o.fullName);
|
|
102
|
+
co.members = [...new Set(['*', ...co.members, ...std])].sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b)));
|
|
103
|
+
log.info(`CustomObject: * plus ${std.length} standard object(s)`);
|
|
104
|
+
} catch (e) { log.warn(`Standard objects listing failed, keeping the template list (${e.message.slice(0, 120)})`); }
|
|
105
|
+
|
|
106
|
+
const xml = ['<?xml version="1.0" encoding="UTF-8"?>', '<Package xmlns="http://soap.sforce.com/2006/04/metadata">',
|
|
107
|
+
...kept.filter((t) => t.members.length).map((t) => ` <types>\n${t.members.map((m) => ` <members>${m}</members>`).join('\n')}\n <name>${t.name}</name>\n </types>`),
|
|
108
|
+
` <version>${version}</version>`, '</Package>', ''].join('\n');
|
|
109
|
+
writeText(out, xml);
|
|
110
|
+
writeJson(ctx.p('.snapshot', 'manifest-report.json'), { mode, kept: kept.map((t) => t.name), dropped: dropped.map((t) => t.name) });
|
|
111
|
+
log.ok(`Manifest written: ${path.relative(ctx.project, out)} (${kept.length} types${dropped.length ? `, ${dropped.length} not available in this org` : ''})`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Retrieve the manifest into tadforce/.snapshot/source, type batch by type batch. force-app is never touched. */
|
|
116
|
+
export async function retrieve(args) {
|
|
117
|
+
const ctx = loadWorkspace(args.cwd || process.cwd());
|
|
118
|
+
const alias = requireAlias(ctx, args);
|
|
119
|
+
const mf = ctx.p('.snapshot', 'package.xml');
|
|
120
|
+
if (!exists(mf)) throw new Error('No manifest: run `tadforce manifest` first.');
|
|
121
|
+
const xml = readText(mf);
|
|
122
|
+
const types = [...xml.matchAll(/<types>[\s\S]*?<\/types>/g)].map((m) => m[0]);
|
|
123
|
+
const version = /<version>([^<]+)<\/version>/.exec(xml)[1];
|
|
124
|
+
const size = Number(args.batchSize || 12);
|
|
125
|
+
const outDir = ctx.p(ctx.cfg.source.dir);
|
|
126
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
127
|
+
log.banner('retrieve');
|
|
128
|
+
const failed = [];
|
|
129
|
+
const runBatch = async (batch, label) => {
|
|
130
|
+
const p = ctx.p('.snapshot', 'batch.xml');
|
|
131
|
+
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);
|
|
134
|
+
};
|
|
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]);
|
|
138
|
+
try {
|
|
139
|
+
await runBatch(batch, `${names.join(', ')}`);
|
|
140
|
+
} 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)})`); }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
writeJson(ctx.p('.snapshot', 'retrieve-report.json'), { failed });
|
|
149
|
+
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
|
+
return 0;
|
|
151
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// `tadforce review`: open the document to comment it (Save writes back to the same file),
|
|
2
|
+
// and list the open comments for the agent (/tadforce-review folds them into the sources).
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { exists } from '../util/fsx.js';
|
|
5
|
+
import { loadWorkspace } from '../util/config.js';
|
|
6
|
+
import { outputName } from '../build/render.js';
|
|
7
|
+
import { serveDocument, openInBrowser } from '../build/serve.js';
|
|
8
|
+
import { log } from '../util/log.js';
|
|
9
|
+
import * as docs from './docs.js';
|
|
10
|
+
|
|
11
|
+
export function documentPath(ctx, args) {
|
|
12
|
+
const doc = args.doc && args.doc !== 'all' ? args.doc : 'tad';
|
|
13
|
+
return args._[1] ? path.resolve(args._[1]) : path.join(ctx.p(ctx.cfg.outputs.dir || 'out'), outputName(ctx.cfg, doc) + '.html');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Serve the file until Ctrl+C. Every Save in the page replaces this file, comments included. */
|
|
17
|
+
export async function open(file) {
|
|
18
|
+
const s = await serveDocument(file);
|
|
19
|
+
log.ok(`Open: ${s.url}`);
|
|
20
|
+
log.info(`« Save » in the page writes into ${file} — one file, the comments stay in it. Ctrl+C to stop.`);
|
|
21
|
+
openInBrowser(s.url);
|
|
22
|
+
await new Promise((resolve) => { process.once('SIGINT', resolve); process.once('SIGTERM', resolve); });
|
|
23
|
+
await s.close();
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function run(args) {
|
|
28
|
+
const ctx = loadWorkspace(args.cwd || process.cwd());
|
|
29
|
+
const file = documentPath(ctx, args);
|
|
30
|
+
if (!exists(file)) throw new Error(`No document at ${file}: run \`tadforce build\` first.`);
|
|
31
|
+
await docs.comments({ ...args, _: ['comments', file] });
|
|
32
|
+
if (args.json || args.open === false || !process.stdout.isTTY) return 0;
|
|
33
|
+
console.log('');
|
|
34
|
+
return open(file);
|
|
35
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// `tadforce spec <id>` and `tadforce next`: what to write, with the facts to write it from.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import { exists, readText, readYaml } from '../util/fsx.js';
|
|
6
|
+
import { loadWorkspace } from '../util/config.js';
|
|
7
|
+
import { TEMPLATES } from '../util/paths.js';
|
|
8
|
+
import { loadFacts } from '../facts/index.js';
|
|
9
|
+
import { resolveConditionals, get } from '../build/directives.js';
|
|
10
|
+
import { DOCS } from '../build/render.js';
|
|
11
|
+
import { log } from '../util/log.js';
|
|
12
|
+
|
|
13
|
+
const specs = () => readYaml(path.join(TEMPLATES, 'specs', 'sections.yml'), {}).sections || {};
|
|
14
|
+
|
|
15
|
+
/** Compact view of a facts path: arrays are summarised (count + first items), long strings cut. */
|
|
16
|
+
function excerpt(value, depth = 0) {
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
const items = value.slice(0, depth ? 8 : 25).map((v) => excerpt(v, depth + 1));
|
|
19
|
+
return value.length > items.length ? { count: value.length, first: items } : items;
|
|
20
|
+
}
|
|
21
|
+
if (value && typeof value === 'object') {
|
|
22
|
+
if (depth > 2) return '{…}';
|
|
23
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, excerpt(v, depth + 1)]));
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === 'string' && value.length > 200) return value.slice(0, 199) + '…';
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function specWithFacts(ctx, id) {
|
|
30
|
+
const s = specs()[id];
|
|
31
|
+
if (!s) return null;
|
|
32
|
+
const facts = loadFacts(ctx.ws) || {};
|
|
33
|
+
const out = { id, ...s, factsExcerpt: {} };
|
|
34
|
+
for (const p of s.facts || []) {
|
|
35
|
+
if (p.startsWith('knowledge/')) {
|
|
36
|
+
const dir = ctx.p(p);
|
|
37
|
+
out.factsExcerpt[p] = exists(dir) ? fs.readdirSync(dir) : [];
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const v = get(facts, p) ?? get(facts.counts || {}, p);
|
|
41
|
+
if (v !== undefined) out.factsExcerpt[p] = excerpt(v);
|
|
42
|
+
}
|
|
43
|
+
if (s.modules) {
|
|
44
|
+
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
|
+
out.readModules = refs.map((r) => `skills/tadforce/references/modules/${r}.md`);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function spec(args) {
|
|
51
|
+
const ctx = loadWorkspace(args.cwd || process.cwd());
|
|
52
|
+
const id = args._[1];
|
|
53
|
+
if (!id) { console.log(Object.keys(specs()).join('\n')); return 0; }
|
|
54
|
+
const s = specWithFacts(ctx, id);
|
|
55
|
+
if (!s) { log.fail(`Unknown section "${id}".`); return 1; }
|
|
56
|
+
console.log(args.json ? JSON.stringify(s, null, 2) : YAML.stringify(s, { lineWidth: 120 }));
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** First section still to write (in reading order), among the sections active for this org. */
|
|
61
|
+
export async function next(args) {
|
|
62
|
+
const ctx = loadWorkspace(args.cwd || process.cwd());
|
|
63
|
+
const facts = loadFacts(ctx.ws);
|
|
64
|
+
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() };
|
|
66
|
+
const docs = !args.doc || args.doc === 'all' ? DOCS : [args.doc];
|
|
67
|
+
let remaining = 0, first = null;
|
|
68
|
+
for (const doc of docs) {
|
|
69
|
+
const dir = ctx.p('src', doc);
|
|
70
|
+
if (!exists(dir)) continue;
|
|
71
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md')).sort()) {
|
|
72
|
+
const raw = readText(path.join(dir, f));
|
|
73
|
+
const active = resolveConditionals(raw, scope);
|
|
74
|
+
for (const m of active.matchAll(/<!--\s*tad:pending\s+([\w.-]+)\s*-->/g)) {
|
|
75
|
+
remaining++;
|
|
76
|
+
if (!first) {
|
|
77
|
+
const line = raw.split('\n').findIndex((l) => l.includes(m[0])) + 1;
|
|
78
|
+
first = { doc, file: path.relative(ctx.project, path.join(dir, f)).split(path.sep).join('/'), line, marker: m[0] };
|
|
79
|
+
first.spec = specWithFacts(ctx, m[1]) || { id: m[1] };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const result = { remaining, next: first };
|
|
85
|
+
if (args.json) { console.log(JSON.stringify(result, null, 2)); return 0; }
|
|
86
|
+
if (!first) { log.ok('Every section is written. Run `tadforce build --pdf --docx`.'); return 0; }
|
|
87
|
+
log.banner(`next · ${remaining} section(s) to write`);
|
|
88
|
+
console.log(`${first.file}:${first.line} ${first.marker}\n`);
|
|
89
|
+
console.log(YAML.stringify(first.spec, { lineWidth: 120 }));
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// `tadforce update`: a few months later, bring an existing document up to date. The previous facts are
|
|
2
|
+
// archived, the org is read again, and only the sections touched by what changed are handed back to the agent.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { exists, readJson, readText, readYaml, writeJson } from '../util/fsx.js';
|
|
6
|
+
import { AREAS, fingerprints } from '../facts/fingerprints.js';
|
|
7
|
+
import { loadWorkspace, sourceDirs } from '../util/config.js';
|
|
8
|
+
import { TEMPLATES } from '../util/paths.js';
|
|
9
|
+
import { log, c } from '../util/log.js';
|
|
10
|
+
import { templateLang } from '../util/i18n.js';
|
|
11
|
+
import * as org from './org.js';
|
|
12
|
+
import * as docs from './docs.js';
|
|
13
|
+
|
|
14
|
+
/** Component name from a metadata path: classes/Foo.cls → Foo, lwc/bar/bar.js → bar, objects/X/fields/Y… → X.Y. */
|
|
15
|
+
function component(rel) {
|
|
16
|
+
const [folder, ...rest] = rel.split('/');
|
|
17
|
+
const strip = (s) => s.replace(/(-meta)?\.xml$/, '').replace(/\.[^.]+$/, '');
|
|
18
|
+
if (folder === 'objects' && rest.length > 2) return `${rest[0]}.${strip(rest[2])}`;
|
|
19
|
+
if (rest.length > 1) return rest[0];
|
|
20
|
+
return strip(rest[0] || folder);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function diffFingerprints(before, after) {
|
|
24
|
+
const changes = { added: new Map(), removed: new Map(), changed: new Map() };
|
|
25
|
+
const put = (kind, rel) => { const key = `${rel.split('/')[0]}:${component(rel)}`; if (!changes[kind].has(key)) changes[kind].set(key, { folder: rel.split('/')[0], name: component(rel) }); };
|
|
26
|
+
for (const [rel, h] of Object.entries(after)) { if (!(rel in before)) put('added', rel); else if (before[rel] !== h) put('changed', rel); }
|
|
27
|
+
for (const rel of Object.keys(before)) if (!(rel in after)) put('removed', rel);
|
|
28
|
+
// a component with some files added and others changed counts once, as changed
|
|
29
|
+
for (const k of changes.changed.keys()) { changes.added.delete(k); changes.removed.delete(k); }
|
|
30
|
+
for (const k of changes.added.keys()) if (changes.removed.has(k)) { changes.changed.set(k, changes.added.get(k)); changes.added.delete(k); changes.removed.delete(k); }
|
|
31
|
+
return Object.fromEntries(Object.entries(changes).map(([k, m]) => [k, [...m.values()].sort((a, b) => (a.folder + a.name).localeCompare(b.folder + b.name))]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Counts that moved: { 'apex.classes': [132, 140] }. */
|
|
35
|
+
export function diffCounts(a = {}, b = {}, prefix = '') {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
38
|
+
const x = a[k], y = b[k], key = prefix ? `${prefix}.${k}` : k;
|
|
39
|
+
if (x && typeof x === 'object' || y && typeof y === 'object') Object.assign(out, diffCounts(x || {}, y || {}, key));
|
|
40
|
+
else if (x !== y) out[key] = [x ?? 0, y ?? 0];
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where each section lives: spec id → chapter file and the heading anchor above its marker. */
|
|
46
|
+
function sectionLocations(lang) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const doc of ['tad', 'axes']) {
|
|
49
|
+
const dir = path.join(TEMPLATES, doc, lang);
|
|
50
|
+
if (!exists(dir)) continue;
|
|
51
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md')).sort()) {
|
|
52
|
+
let anchor = null;
|
|
53
|
+
for (const line of readText(path.join(dir, f)).split('\n')) {
|
|
54
|
+
const h = /^#{1,4} .*\{[^}]*#([\w-]+)[^}]*\}\s*$/.exec(line);
|
|
55
|
+
if (h) anchor = h[1];
|
|
56
|
+
const m = /<!--\s*tad:pending\s+([\w.-]+)\s*-->/.exec(line);
|
|
57
|
+
if (m) out[m[1]] = { doc, file: `src/${doc}/${f}`, anchor };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Sections whose spec reads a facts area that changed. */
|
|
65
|
+
export function affectedSections(areas, specs, locations) {
|
|
66
|
+
const hits = [];
|
|
67
|
+
for (const [id, s] of Object.entries(specs)) {
|
|
68
|
+
const reasons = [];
|
|
69
|
+
for (const p of s.facts || []) {
|
|
70
|
+
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);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (reasons.length) hits.push({ id, ...(locations[id] || {}), reasons: [...new Set(reasons)].slice(0, 12) });
|
|
76
|
+
}
|
|
77
|
+
return hits;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const nextVersion = (v) => { const m = /^(\d+)\.(\d+)$/.exec(String(v)); return m ? `${m[1]}.${Number(m[2]) + 1}` : `${v}.1`; };
|
|
81
|
+
|
|
82
|
+
export async function run(args) {
|
|
83
|
+
const ctx = loadWorkspace(args.cwd || process.cwd());
|
|
84
|
+
const say = (fn) => { if (!args.json) fn(); };
|
|
85
|
+
const before = ctx.p('facts', 'facts.json');
|
|
86
|
+
if (!exists(before)) throw new Error('No previous facts: run `tadforce doctor` and `tadforce build` first.');
|
|
87
|
+
say(() => log.banner('update'));
|
|
88
|
+
|
|
89
|
+
// 1. Archive what the current document was written from.
|
|
90
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
91
|
+
const archive = ctx.p('facts', 'history', stamp);
|
|
92
|
+
fs.mkdirSync(archive, { recursive: true });
|
|
93
|
+
const oldFacts = readJson(before);
|
|
94
|
+
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
|
+
|
|
99
|
+
// 2. Read the org again (a fresh snapshot: deleted components must disappear too).
|
|
100
|
+
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; } };
|
|
102
|
+
if (mode !== 'local' && !args.skipRetrieve) {
|
|
103
|
+
const snap = ctx.p(ctx.cfg.source.dir);
|
|
104
|
+
if (exists(snap)) fs.rmSync(snap, { recursive: true, force: true });
|
|
105
|
+
for (const fn of [org.manifest, org.retrieve]) { const code = await quiet(() => fn({ ...args, cwd: ctx.project })); if (code) return code; }
|
|
106
|
+
}
|
|
107
|
+
await quiet(() => docs.facts({ ...args, cwd: ctx.project, org: mode === 'local' || args.skipRetrieve ? false : ctx.cfg.org.alias }));
|
|
108
|
+
const ctx2 = loadWorkspace(ctx.project);
|
|
109
|
+
const newPrints = fingerprints(sourceDirs(ctx2));
|
|
110
|
+
writeJson(ctx2.p('facts', 'fingerprints.json'), newPrints);
|
|
111
|
+
const newFacts = readJson(ctx2.p('facts', 'facts.json'));
|
|
112
|
+
|
|
113
|
+
// 3. What changed, and which sections it touches.
|
|
114
|
+
const diff = diffFingerprints(oldPrints, newPrints);
|
|
115
|
+
const counts = diffCounts(oldFacts.counts, newFacts.counts);
|
|
116
|
+
const areas = {};
|
|
117
|
+
const note = (area, why) => { (areas[area] ||= []).push(why); };
|
|
118
|
+
for (const [kind, list] of Object.entries(diff)) for (const x of list) note(AREAS[x.folder], `${kind} ${x.folder}:${x.name}`);
|
|
119
|
+
const COUNT_AREAS = { objects: 'data', fields: 'data', recordTypes: 'data', validationRules: 'data', cmdt: 'data.cmdtTypes', flows: 'automation.flows', omni: 'automation.omnistudio' };
|
|
120
|
+
for (const [k, [a, b]] of Object.entries(counts)) { const top = k.split('.')[0]; if (top !== 'exclusions') note(COUNT_AREAS[top] || top, `count ${k}: ${a} → ${b}`); }
|
|
121
|
+
const modulesBefore = (oldFacts.modules?.clouds || []).map((x) => x.name), modulesAfter = (newFacts.modules?.clouds || []).map((x) => x.name);
|
|
122
|
+
for (const m of modulesAfter.filter((x) => !modulesBefore.includes(x))) note('modules', `new product detected: ${m}`);
|
|
123
|
+
for (const m of modulesBefore.filter((x) => !modulesAfter.includes(x))) note('modules', `product no longer detected: ${m}`);
|
|
124
|
+
const specs = readYaml(path.join(TEMPLATES, 'specs', 'sections.yml'), {}).sections || {};
|
|
125
|
+
const sections = affectedSections(areas, specs, sectionLocations(templateLang(ctx2.cfg.language)));
|
|
126
|
+
|
|
127
|
+
// 4. New version in the document control.
|
|
128
|
+
const cfg = ctx2.cfg;
|
|
129
|
+
const total = diff.added.length + diff.changed.length + diff.removed.length;
|
|
130
|
+
const from = cfg.document.version;
|
|
131
|
+
if (total && args.bump !== false) {
|
|
132
|
+
const by = [cfg.document.author, cfg.integrator.name].filter(Boolean).join(' — ') || cfg.client.name;
|
|
133
|
+
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
|
+
cfg.document.version = nextVersion(from);
|
|
135
|
+
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`;
|
|
139
|
+
cfg.document.history = [...history, { version: cfg.document.version, date: stamp, author: by, purpose }];
|
|
140
|
+
ctx2.save(cfg);
|
|
141
|
+
}
|
|
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);
|
|
144
|
+
if (args.json) { console.log(JSON.stringify(report, null, 2)); return 0; }
|
|
145
|
+
|
|
146
|
+
if (!total && !Object.keys(counts).length) { log.ok('Nothing changed since the last version. The document is up to date.'); return 0; }
|
|
147
|
+
log.ok(`${diff.added.length} added · ${diff.changed.length} changed · ${diff.removed.length} removed · ${Object.keys(counts).length} count(s) moved`);
|
|
148
|
+
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
|
+
if (total) log.ok(`Version ${from} → ${cfg.document.version} (history updated)`);
|
|
150
|
+
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('; ')}`);
|
|
152
|
+
log.human('let your agent rewrite these sections (/tadforce-update), then `tadforce build`. Numbers and generated tables update by themselves.');
|
|
153
|
+
return 0;
|
|
154
|
+
}
|