@hone-ai/cli 1.4.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/bin/hone.js +2 -0
- package/hone-cli.js +4006 -0
- package/lib/README.md +119 -0
- package/lib/adversarial-negative-lint.js +149 -0
- package/lib/audit.js +156 -0
- package/lib/auto-detect.js +213 -0
- package/lib/autofix-guardrails.js +124 -0
- package/lib/branch-protection.js +256 -0
- package/lib/ci-classifier.js +150 -0
- package/lib/ci-failures.js +173 -0
- package/lib/claude-md-tokens.js +71 -0
- package/lib/compliance-check.js +62 -0
- package/lib/config-augment.js +133 -0
- package/lib/config-update.js +70 -0
- package/lib/dependency-audit.js +108 -0
- package/lib/derive-domain.js +185 -0
- package/lib/doc-registry.js +63 -0
- package/lib/doctor-admin-merge.js +185 -0
- package/lib/doctor-bind-default.js +118 -0
- package/lib/doctor-docs.js +205 -0
- package/lib/doctor-placeholders.js +144 -0
- package/lib/doctor-skill-staleness.js +122 -0
- package/lib/domain-skill-template.md +114 -0
- package/lib/editor-detect.js +169 -0
- package/lib/fast-track-ratify.js +133 -0
- package/lib/git-helpers.js +109 -0
- package/lib/hook-templates/pre-commit.sh +54 -0
- package/lib/hook-templates/pre-push.sh +72 -0
- package/lib/install-hooks.js +205 -0
- package/lib/knowledge-graph.js +188 -0
- package/lib/learnings-audit.js +254 -0
- package/lib/learnings-parse.js +331 -0
- package/lib/learnings-sync.js +75 -0
- package/lib/mcp-detect.js +154 -0
- package/lib/metrics-collect.js +214 -0
- package/lib/overlay-merge.js +267 -0
- package/lib/performance-analyzer.js +142 -0
- package/lib/pipeline-config.js +83 -0
- package/lib/pipeline-status.js +207 -0
- package/lib/pipeline-validate.js +322 -0
- package/lib/platform-detect.js +86 -0
- package/lib/platform-discover.js +334 -0
- package/lib/publish-learning.js +160 -0
- package/lib/python-install.js +84 -0
- package/lib/refresh-check.js +67 -0
- package/lib/refresh-knowledge.js +360 -0
- package/lib/rule-resolver.js +146 -0
- package/lib/security-scanner.js +168 -0
- package/lib/setup-grounding.js +138 -0
- package/lib/skill-assertions.js +276 -0
- package/lib/skill-audit-render.js +158 -0
- package/lib/skill-audit.js +391 -0
- package/lib/stack-detect.js +170 -0
- package/lib/stack-paths.js +285 -0
- package/lib/story-classifier-extract.js +203 -0
- package/lib/story-classifier.js +282 -0
- package/lib/sync-overwrite.js +47 -0
- package/lib/synthetic-pipeline.js +299 -0
- package/lib/validate-metadata.js +175 -0
- package/package.json +41 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* platform-detect.js — Fingerprint-based platform detection (H-028d).
|
|
4
|
+
*
|
|
5
|
+
* Pure helper with injected I/O (same shape as cli/lib/compliance-check.js
|
|
6
|
+
* and cli/lib/auto-detect.js). The caller passes a `fileExists` callback;
|
|
7
|
+
* helper returns the array of detected platforms based on root-level
|
|
8
|
+
* fingerprint files.
|
|
9
|
+
*
|
|
10
|
+
* Generalizes H-028c's hardcoded SF/NS CONFIG support: any platform with
|
|
11
|
+
* a recognizable root-level fingerprint can be detected and used to
|
|
12
|
+
* activate per-platform CONFIG patterns.
|
|
13
|
+
*
|
|
14
|
+
* The fingerprint table is intentionally NOT exhaustive — it covers the
|
|
15
|
+
* 4 platforms with the cleanest fingerprint signal:
|
|
16
|
+
* - Salesforce (sfdx-project.json)
|
|
17
|
+
* - NetSuite (suitecloud.config.js / project.json)
|
|
18
|
+
* - dbt (dbt_project.yml)
|
|
19
|
+
* - Terraform (main.tf / versions.tf / terraform.tfstate)
|
|
20
|
+
* Adding more platforms is purely additive — append to PLATFORM_FINGERPRINTS.
|
|
21
|
+
*
|
|
22
|
+
* Issue: https://github.com/subbareddyvani/hone-server/issues/51 (follow-up to H-028c).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const PLATFORM_FINGERPRINTS = Object.freeze({
|
|
26
|
+
salesforce: {
|
|
27
|
+
files: ['sfdx-project.json'],
|
|
28
|
+
description: 'Salesforce SFDX project',
|
|
29
|
+
},
|
|
30
|
+
netsuite: {
|
|
31
|
+
// SDF projects use suitecloud.config.js; SuiteApp packages use project.json.
|
|
32
|
+
// Either signals a NetSuite project.
|
|
33
|
+
files: ['suitecloud.config.js', 'project.json'],
|
|
34
|
+
description: 'NetSuite SDF / SuiteApp project',
|
|
35
|
+
},
|
|
36
|
+
dbt: {
|
|
37
|
+
files: ['dbt_project.yml'],
|
|
38
|
+
description: 'dbt analytics-engineering project',
|
|
39
|
+
},
|
|
40
|
+
terraform: {
|
|
41
|
+
// Terraform has no single canonical root file. main.tf is the most
|
|
42
|
+
// common convention; versions.tf appears in projects pinning provider
|
|
43
|
+
// versions; terraform.tfstate is the post-apply state file.
|
|
44
|
+
files: ['main.tf', 'versions.tf', 'terraform.tfstate'],
|
|
45
|
+
description: 'Terraform infrastructure-as-code project',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Detect installed platforms by checking fingerprint files at repoRoot.
|
|
51
|
+
*
|
|
52
|
+
* Each PLATFORM_FINGERPRINTS entry uses any-of semantics: if ANY of its
|
|
53
|
+
* fingerprint files exists at the repo root, the platform is detected.
|
|
54
|
+
* Multiple platforms can be detected simultaneously (e.g., a repo with
|
|
55
|
+
* BOTH `sfdx-project.json` AND `dbt_project.yml`).
|
|
56
|
+
*
|
|
57
|
+
* @param {object} [opts]
|
|
58
|
+
* @param {string} [opts.repoRoot] - absolute path to repo root.
|
|
59
|
+
* Currently unused by the helper itself (the `fileExists` callback
|
|
60
|
+
* carries the path context), but reserved for future enhancements
|
|
61
|
+
* like depth-limited file walks.
|
|
62
|
+
* @param {(relativePath: string) => boolean} [opts.fileExists] -
|
|
63
|
+
* injected filesystem check. Caller wraps `fs.existsSync` or a test
|
|
64
|
+
* stub. Receives a path RELATIVE to repoRoot.
|
|
65
|
+
* @returns {string[]} - platform keys from PLATFORM_FINGERPRINTS in
|
|
66
|
+
* table iteration order (deterministic for tests).
|
|
67
|
+
*/
|
|
68
|
+
function detectPlatforms(opts = {}) {
|
|
69
|
+
const { repoRoot, fileExists } = opts;
|
|
70
|
+
if (!repoRoot || typeof fileExists !== 'function') return [];
|
|
71
|
+
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const [platform, { files }] of Object.entries(PLATFORM_FINGERPRINTS)) {
|
|
74
|
+
if (files.some((f) => {
|
|
75
|
+
try { return fileExists(f) === true; } catch { return false; }
|
|
76
|
+
})) {
|
|
77
|
+
out.push(platform);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
PLATFORM_FINGERPRINTS,
|
|
85
|
+
detectPlatforms,
|
|
86
|
+
};
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* platform-discover.js — Filesystem-based metadata type discovery (HC-010).
|
|
4
|
+
*
|
|
5
|
+
* Pure helper with injected I/O (same pattern as platform-detect.js).
|
|
6
|
+
* Scans repo filesystem to discover which platform metadata types
|
|
7
|
+
* actually exist, with file counts. Skips empty directories.
|
|
8
|
+
*
|
|
9
|
+
* Reads the platform's project descriptor (sfdx-project.json for
|
|
10
|
+
* Salesforce, suitecloud.config.js for NetSuite) to find source roots
|
|
11
|
+
* instead of hardcoding paths.
|
|
12
|
+
*
|
|
13
|
+
* Output feeds:
|
|
14
|
+
* - .pipeline-config.yml platform.metadata_types (for derive prompt)
|
|
15
|
+
* - .pipeline-config.yml platform.config_paths (for stack-paths.js classifier)
|
|
16
|
+
*
|
|
17
|
+
* Architecture: docs/architecture/platform-auto-discovery-v1.md (Tier 1)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ── Salesforce Type Classification ─────────────────────────────
|
|
21
|
+
// Maps directory names under <packageDir>/main/default/ to categories.
|
|
22
|
+
// Source: Salesforce Metadata API + SFDX source format.
|
|
23
|
+
const SALESFORCE_TYPE_MAP = Object.freeze({
|
|
24
|
+
// CODE — testable via Apex tests + Jest
|
|
25
|
+
classes: 'code',
|
|
26
|
+
triggers: 'code',
|
|
27
|
+
lwc: 'code',
|
|
28
|
+
aura: 'code',
|
|
29
|
+
pages: 'code', // Visualforce pages
|
|
30
|
+
components: 'code', // Visualforce components
|
|
31
|
+
|
|
32
|
+
// CONFIG — deployable via SFDX, testable via sandbox deploy
|
|
33
|
+
objects: 'config',
|
|
34
|
+
flows: 'config',
|
|
35
|
+
flowDefinitions: 'config',
|
|
36
|
+
profiles: 'config',
|
|
37
|
+
permissionsets: 'config',
|
|
38
|
+
permissionsetgroups: 'config',
|
|
39
|
+
layouts: 'config',
|
|
40
|
+
flexipages: 'config',
|
|
41
|
+
compactLayouts: 'config',
|
|
42
|
+
customMetadata: 'config',
|
|
43
|
+
customSettings: 'config',
|
|
44
|
+
globalValueSets: 'config',
|
|
45
|
+
standardValueSets: 'config',
|
|
46
|
+
applications: 'config',
|
|
47
|
+
tabs: 'config',
|
|
48
|
+
workflows: 'config',
|
|
49
|
+
approvalProcesses: 'config',
|
|
50
|
+
sharingRules: 'config',
|
|
51
|
+
assignmentRules: 'config',
|
|
52
|
+
autoResponseRules: 'config',
|
|
53
|
+
escalationRules: 'config',
|
|
54
|
+
matchingRules: 'config',
|
|
55
|
+
duplicateRules: 'config',
|
|
56
|
+
labels: 'config',
|
|
57
|
+
translations: 'config',
|
|
58
|
+
objectTranslations: 'config',
|
|
59
|
+
connectedApps: 'config',
|
|
60
|
+
namedCredentials: 'config',
|
|
61
|
+
externalCredentials: 'config',
|
|
62
|
+
remoteSiteSettings: 'config',
|
|
63
|
+
cspTrustedSites: 'config',
|
|
64
|
+
corsWhitelistOrigins: 'config',
|
|
65
|
+
externalDataSources: 'config',
|
|
66
|
+
staticresources: 'config',
|
|
67
|
+
contentassets: 'config',
|
|
68
|
+
email: 'config',
|
|
69
|
+
reports: 'config',
|
|
70
|
+
reportTypes: 'config',
|
|
71
|
+
dashboards: 'config',
|
|
72
|
+
settings: 'config',
|
|
73
|
+
quickActions: 'config',
|
|
74
|
+
homePageComponents: 'config',
|
|
75
|
+
homePageLayouts: 'config',
|
|
76
|
+
pathAssistants: 'config',
|
|
77
|
+
roles: 'config',
|
|
78
|
+
groups: 'config',
|
|
79
|
+
queues: 'config',
|
|
80
|
+
customPermissions: 'config',
|
|
81
|
+
notificationTypes: 'config',
|
|
82
|
+
platformEventChannels: 'config',
|
|
83
|
+
platformEventChannelMembers: 'config',
|
|
84
|
+
messageChannels: 'config',
|
|
85
|
+
digitalExperiences: 'config',
|
|
86
|
+
experiences: 'config',
|
|
87
|
+
sites: 'config',
|
|
88
|
+
bots: 'config',
|
|
89
|
+
certs: 'config',
|
|
90
|
+
territory2Models: 'config',
|
|
91
|
+
territory2Types: 'config',
|
|
92
|
+
territory2Rules: 'config',
|
|
93
|
+
|
|
94
|
+
// TEST
|
|
95
|
+
testSuites: 'test',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── NetSuite Type Classification ───────────────────────────────
|
|
99
|
+
// Maps SuiteScript directory names + Object filename prefixes to categories.
|
|
100
|
+
const NETSUITE_SCRIPT_TYPES = Object.freeze({
|
|
101
|
+
RESTlets: 'code',
|
|
102
|
+
Suitelets: 'code',
|
|
103
|
+
UserEventScripts: 'code',
|
|
104
|
+
'User Event Scripts': 'code',
|
|
105
|
+
MapReduce: 'code',
|
|
106
|
+
'Map Reduce': 'code',
|
|
107
|
+
ScheduledScripts: 'code',
|
|
108
|
+
'Scheduled Scripts': 'code',
|
|
109
|
+
WorkflowActions: 'code',
|
|
110
|
+
'Workflow Actions': 'code',
|
|
111
|
+
WorkflowActionScripts: 'code',
|
|
112
|
+
ClientScripts: 'code',
|
|
113
|
+
'Client Scripts': 'code',
|
|
114
|
+
MassUpdate: 'code',
|
|
115
|
+
'Mass Update': 'code',
|
|
116
|
+
BundleInstallation: 'code',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const NETSUITE_OBJECT_PREFIXES = Object.freeze({
|
|
120
|
+
customrecord: 'config',
|
|
121
|
+
customlist: 'config',
|
|
122
|
+
customsearch: 'config',
|
|
123
|
+
customworkflow: 'config',
|
|
124
|
+
customrole: 'config',
|
|
125
|
+
custentryform: 'config',
|
|
126
|
+
custtransactionform: 'config',
|
|
127
|
+
customsegment: 'config',
|
|
128
|
+
custcollection: 'config',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── Empty Result ───────────────────────────────────────────────
|
|
132
|
+
function emptyResult() {
|
|
133
|
+
return {
|
|
134
|
+
metadata_types: { code: [], config: [], test: [] },
|
|
135
|
+
config_paths: [],
|
|
136
|
+
source_roots: [],
|
|
137
|
+
warnings: [],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Salesforce Discovery ───────────────────────────────────────
|
|
142
|
+
function discoverSalesforce(readFile, listDir) {
|
|
143
|
+
const result = emptyResult();
|
|
144
|
+
|
|
145
|
+
// 1. Read sfdx-project.json for packageDirectories
|
|
146
|
+
const raw = readFile('sfdx-project.json');
|
|
147
|
+
if (!raw) {
|
|
148
|
+
result.warnings.push('sfdx-project.json not found or unreadable');
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let config;
|
|
153
|
+
try { config = JSON.parse(raw); }
|
|
154
|
+
catch (e) {
|
|
155
|
+
result.warnings.push(`sfdx-project.json parse error: ${e.message}`);
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const packageDirs = (config.packageDirectories || [])
|
|
160
|
+
.map(d => typeof d === 'string' ? d : d.path)
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
|
|
163
|
+
if (packageDirs.length === 0) {
|
|
164
|
+
result.warnings.push('sfdx-project.json has no packageDirectories');
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
result.source_roots = [...packageDirs];
|
|
169
|
+
|
|
170
|
+
// 2. Scan each packageDir/main/default/ for metadata type dirs
|
|
171
|
+
const typeCounts = {}; // { type: { category, paths: Set, count } }
|
|
172
|
+
|
|
173
|
+
for (const pkgDir of packageDirs) {
|
|
174
|
+
const defaultDir = `${pkgDir}/main/default`;
|
|
175
|
+
const entries = listDir(defaultDir);
|
|
176
|
+
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
const category = SALESFORCE_TYPE_MAP[entry];
|
|
179
|
+
if (!category) continue; // unknown dir, skip
|
|
180
|
+
|
|
181
|
+
const entryPath = `${defaultDir}/${entry}`;
|
|
182
|
+
const files = listDir(entryPath);
|
|
183
|
+
const fileCount = files.length;
|
|
184
|
+
|
|
185
|
+
if (fileCount === 0) {
|
|
186
|
+
result.warnings.push(`${entryPath}/ empty — skipped (false signal)`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!typeCounts[entry]) {
|
|
191
|
+
typeCounts[entry] = { category, paths: new Set(), count: 0 };
|
|
192
|
+
}
|
|
193
|
+
typeCounts[entry].paths.add(entryPath);
|
|
194
|
+
typeCounts[entry].count += fileCount;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 3. Build metadata_types and config_paths
|
|
199
|
+
for (const [type, data] of Object.entries(typeCounts)) {
|
|
200
|
+
const entry = {
|
|
201
|
+
type,
|
|
202
|
+
path: [...data.paths].join(', '),
|
|
203
|
+
count: data.count,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
result.metadata_types[data.category].push(entry);
|
|
207
|
+
|
|
208
|
+
if (data.category === 'config') {
|
|
209
|
+
for (const p of data.paths) {
|
|
210
|
+
result.config_paths.push(`^${p}/`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Sort each category by count descending (most files first)
|
|
216
|
+
for (const cat of ['code', 'config', 'test']) {
|
|
217
|
+
result.metadata_types[cat].sort((a, b) => b.count - a.count);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── NetSuite Discovery ─────────────────────────────────────────
|
|
224
|
+
function discoverNetSuite(readFile, listDir) {
|
|
225
|
+
const result = emptyResult();
|
|
226
|
+
|
|
227
|
+
// 1. Read suitecloud.config.js for defaultProjectFolder
|
|
228
|
+
let projectFolder = 'src'; // default
|
|
229
|
+
const configRaw = readFile('suitecloud.config.js');
|
|
230
|
+
if (configRaw) {
|
|
231
|
+
const match = configRaw.match(/defaultProjectFolder\s*:\s*['"]([^'"]+)['"]/);
|
|
232
|
+
if (match) projectFolder = match[1];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
result.source_roots = [projectFolder];
|
|
236
|
+
|
|
237
|
+
// 2. Scan Objects/ for custom object XML files
|
|
238
|
+
const objectsDir = `${projectFolder}/Objects`;
|
|
239
|
+
const objectFiles = listDir(objectsDir);
|
|
240
|
+
|
|
241
|
+
if (objectFiles.length > 0) {
|
|
242
|
+
// Group by prefix
|
|
243
|
+
const prefixCounts = {};
|
|
244
|
+
for (const file of objectFiles) {
|
|
245
|
+
const prefix = Object.keys(NETSUITE_OBJECT_PREFIXES).find(p => file.startsWith(p));
|
|
246
|
+
if (prefix) {
|
|
247
|
+
prefixCounts[prefix] = (prefixCounts[prefix] || 0) + 1;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const [prefix, count] of Object.entries(prefixCounts)) {
|
|
252
|
+
result.metadata_types.config.push({
|
|
253
|
+
type: prefix,
|
|
254
|
+
path: `${objectsDir}/`,
|
|
255
|
+
count,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// config_paths for Objects/
|
|
260
|
+
result.config_paths.push(`^${objectsDir}/`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 3. Scan FileCabinet/SuiteScripts/ for script type dirs
|
|
264
|
+
const scriptsDir = `${projectFolder}/FileCabinet/SuiteScripts`;
|
|
265
|
+
const scriptDirs = listDir(scriptsDir);
|
|
266
|
+
|
|
267
|
+
for (const dir of scriptDirs) {
|
|
268
|
+
const category = NETSUITE_SCRIPT_TYPES[dir];
|
|
269
|
+
if (!category) continue;
|
|
270
|
+
|
|
271
|
+
const dirPath = `${scriptsDir}/${dir}`;
|
|
272
|
+
const files = listDir(dirPath);
|
|
273
|
+
if (files.length === 0) {
|
|
274
|
+
result.warnings.push(`${dirPath}/ empty — skipped`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
result.metadata_types.code.push({
|
|
279
|
+
type: dir.replace(/\s+/g, ''), // normalize "User Event Scripts" → "UserEventScripts"
|
|
280
|
+
path: dirPath,
|
|
281
|
+
count: files.length,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 4. Scan Templates/
|
|
286
|
+
const templatesDir = `${projectFolder}/Templates`;
|
|
287
|
+
const templateFiles = listDir(templatesDir);
|
|
288
|
+
if (templateFiles.length > 0) {
|
|
289
|
+
result.metadata_types.config.push({
|
|
290
|
+
type: 'Templates',
|
|
291
|
+
path: templatesDir,
|
|
292
|
+
count: templateFiles.length,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Sort
|
|
297
|
+
for (const cat of ['code', 'config', 'test']) {
|
|
298
|
+
result.metadata_types[cat].sort((a, b) => b.count - a.count);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Main Entry Point ───────────────────────────────────────────
|
|
305
|
+
/**
|
|
306
|
+
* Discover platform metadata types by scanning the filesystem.
|
|
307
|
+
*
|
|
308
|
+
* @param {object} opts
|
|
309
|
+
* @param {string} opts.platform - 'salesforce' | 'netsuite'
|
|
310
|
+
* @param {string} opts.repoRoot - absolute path (reserved for future use)
|
|
311
|
+
* @param {(relativePath: string) => string|null} opts.readFile - read file content
|
|
312
|
+
* @param {(relativePath: string) => string[]} opts.listDir - list directory entries
|
|
313
|
+
* @returns {{ metadata_types, config_paths, source_roots, warnings }}
|
|
314
|
+
*/
|
|
315
|
+
function discoverPlatformMetadata(opts = {}) {
|
|
316
|
+
const { platform, readFile, listDir } = opts;
|
|
317
|
+
|
|
318
|
+
if (typeof readFile !== 'function' || typeof listDir !== 'function') {
|
|
319
|
+
return emptyResult();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
switch (platform) {
|
|
323
|
+
case 'salesforce': return discoverSalesforce(readFile, listDir);
|
|
324
|
+
case 'netsuite': return discoverNetSuite(readFile, listDir);
|
|
325
|
+
default: return emptyResult();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = {
|
|
330
|
+
discoverPlatformMetadata,
|
|
331
|
+
SALESFORCE_TYPE_MAP,
|
|
332
|
+
NETSUITE_SCRIPT_TYPES,
|
|
333
|
+
NETSUITE_OBJECT_PREFIXES,
|
|
334
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* publish-learning.js — SC-005 (Phase 5 of SC family).
|
|
4
|
+
*
|
|
5
|
+
* Pure helper that publishes a framework-canonical learning from
|
|
6
|
+
* `.github/learnings/<ID>.yml` to `server/seeds/learnings/<ID>.yml`,
|
|
7
|
+
* then flips the source's status from `pending` → `promoted` and
|
|
8
|
+
* stamps a `promoted_at` ISO timestamp.
|
|
9
|
+
*
|
|
10
|
+
* Eligibility:
|
|
11
|
+
* - source must have `share_enterprise: true`
|
|
12
|
+
* - source must have `enterprise_candidate: true`
|
|
13
|
+
* - source must NOT already be `status: promoted` unless `force: true`
|
|
14
|
+
*
|
|
15
|
+
* Failure modes (all return structured result; never throw):
|
|
16
|
+
* - source file missing → { status: 'not-found' }
|
|
17
|
+
* - malformed YAML → { status: 'error', error }
|
|
18
|
+
* - missing required fields → { status: 'not-eligible', error }
|
|
19
|
+
* - share_enterprise / candidate not true → { status: 'not-eligible', error }
|
|
20
|
+
* - already promoted, no --force → { status: 'already-promoted' }
|
|
21
|
+
*
|
|
22
|
+
* Pure-helper-with-injected-IO style (Category B per cli/lib/README.md):
|
|
23
|
+
* - All filesystem and clock reads via Node's fs + new Date() — defaults
|
|
24
|
+
* are real I/O, but injected callbacks would be possible (kept simple
|
|
25
|
+
* here since the helper is small and tests use tmpdirs).
|
|
26
|
+
*
|
|
27
|
+
* Issue: SC-005 — first publish path for framework learnings to reach
|
|
28
|
+
* adopter installs. Captured during SC-004 wrap-up audit.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
const crypto = require('node:crypto');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Publish a framework-canonical learning to the seeds path.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} args
|
|
39
|
+
* @param {string} args.repoRoot framework repo root (where .github/learnings lives)
|
|
40
|
+
* @param {string} args.learningId e.g. 'SC-001'
|
|
41
|
+
* @param {boolean} [args.force] re-publish even if already promoted
|
|
42
|
+
* @param {Function}[args.now] clock injection (returns Date); default: () => new Date()
|
|
43
|
+
* @returns {{ status: 'published' | 'already-promoted' | 'not-eligible' | 'not-found' | 'error',
|
|
44
|
+
* copiedTo?: string, error?: string }}
|
|
45
|
+
*/
|
|
46
|
+
function publishLearning(args) {
|
|
47
|
+
const { repoRoot, learningId, force = false } = args || {};
|
|
48
|
+
const now = args && args.now ? args.now : () => new Date();
|
|
49
|
+
|
|
50
|
+
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
51
|
+
return { status: 'error', error: 'repoRoot is required' };
|
|
52
|
+
}
|
|
53
|
+
if (!learningId || typeof learningId !== 'string') {
|
|
54
|
+
return { status: 'error', error: 'learningId is required' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sourcePath = path.join(repoRoot, '.github/learnings', `${learningId}.yml`);
|
|
58
|
+
const seedDir = path.join(repoRoot, 'server/seeds/learnings');
|
|
59
|
+
const seedPath = path.join(seedDir, `${learningId}.yml`);
|
|
60
|
+
|
|
61
|
+
if (!fs.existsSync(sourcePath)) {
|
|
62
|
+
return { status: 'not-found', error: `learning not found at ${sourcePath}` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let raw;
|
|
66
|
+
try { raw = fs.readFileSync(sourcePath, 'utf8'); }
|
|
67
|
+
catch (e) { return { status: 'error', error: `cannot read source: ${e.message}` }; }
|
|
68
|
+
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
const yaml = require('js-yaml');
|
|
72
|
+
parsed = yaml.load(raw);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return { status: 'error', error: `malformed YAML: ${e.message}` };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
78
|
+
return { status: 'error', error: 'YAML did not parse to an object' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Eligibility checks
|
|
82
|
+
if (parsed.share_enterprise !== true) {
|
|
83
|
+
return { status: 'not-eligible', error: 'share_enterprise must be true' };
|
|
84
|
+
}
|
|
85
|
+
// enterprise_candidate may be at top level OR in any learnings[] entry
|
|
86
|
+
// (real schema puts it per-learning; test fixtures use top-level for brevity)
|
|
87
|
+
const topLevelCandidate = parsed.enterprise_candidate === true;
|
|
88
|
+
const nestedCandidate = Array.isArray(parsed.learnings)
|
|
89
|
+
&& parsed.learnings.some(l => l && l.enterprise_candidate === true);
|
|
90
|
+
if (!topLevelCandidate && !nestedCandidate) {
|
|
91
|
+
return { status: 'not-eligible', error: 'enterprise_candidate must be true (top-level or in any learnings[] entry)' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (parsed.status === 'promoted' && !force) {
|
|
95
|
+
return { status: 'already-promoted', copiedTo: seedPath };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Update source: status + promoted_at
|
|
99
|
+
const stamp = now().toISOString();
|
|
100
|
+
let updated = raw;
|
|
101
|
+
if (/^status:\s*\S+/m.test(updated)) {
|
|
102
|
+
updated = updated.replace(/^status:\s*\S+/m, 'status: promoted');
|
|
103
|
+
} else {
|
|
104
|
+
// No status line; add at top after first non-comment line
|
|
105
|
+
updated = updated.replace(/^(story_id:[^\n]*\n)/, `$1status: promoted\n`);
|
|
106
|
+
}
|
|
107
|
+
// Quote the timestamp so YAML parsers don't auto-convert it to a Date.
|
|
108
|
+
// Other date-ish fields (captured_at) follow the same convention.
|
|
109
|
+
const quotedStamp = `"${stamp}"`;
|
|
110
|
+
if (/^promoted_at:\s*\S+/m.test(updated)) {
|
|
111
|
+
updated = updated.replace(/^promoted_at:\s*\S+.*$/m, `promoted_at: ${quotedStamp}`);
|
|
112
|
+
} else {
|
|
113
|
+
// Insert after status line
|
|
114
|
+
updated = updated.replace(/^(status:\s*promoted)$/m, `$1\npromoted_at: ${quotedStamp}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// HC-005-E: Reverse write order to fix partial-write hazard.
|
|
118
|
+
// Original ordering wrote source first, then seed — which left a
|
|
119
|
+
// promoted-source-without-seed inconsistent state when seed write failed.
|
|
120
|
+
// New ordering: seed first (atomic via tmp+rename); source last.
|
|
121
|
+
// If seed write fails: source untouched; adopter can re-run after fix.
|
|
122
|
+
// If source write fails after seed succeeds: rare; report `partial`.
|
|
123
|
+
|
|
124
|
+
// Ensure seed directory exists
|
|
125
|
+
if (!fs.existsSync(seedDir)) {
|
|
126
|
+
try { fs.mkdirSync(seedDir, { recursive: true }); }
|
|
127
|
+
catch (e) { return { status: 'error', error: `cannot create seed dir: ${e.message}` }; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Atomic seed write: tmp + rename (defense in depth against partial writes)
|
|
131
|
+
const tmpSuffix = crypto.randomBytes(2).toString('hex');
|
|
132
|
+
const tmpSeedPath = `${seedPath}.tmp.${tmpSuffix}`;
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(tmpSeedPath, updated, 'utf8');
|
|
135
|
+
fs.renameSync(tmpSeedPath, seedPath);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
// Clean up tmp file if it exists
|
|
138
|
+
try { if (fs.existsSync(tmpSeedPath)) fs.unlinkSync(tmpSeedPath); } catch {}
|
|
139
|
+
return { status: 'error', error: `cannot write seed: ${e.message}` };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Source-write LAST. If this fails after seed-write succeeded, report
|
|
143
|
+
// partial state so adopter can investigate.
|
|
144
|
+
try {
|
|
145
|
+
fs.writeFileSync(sourcePath, updated, 'utf8');
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return {
|
|
148
|
+
status: 'partial',
|
|
149
|
+
copiedTo: seedPath,
|
|
150
|
+
sourcePath,
|
|
151
|
+
error: `PARTIAL — seed written to ${seedPath} BUT source at ${sourcePath} could not be updated: ${e.message}. ` +
|
|
152
|
+
`Recovery: fix source-file permissions, then re-run \`hone publish-learning --force ${learningId}\`. ` +
|
|
153
|
+
`The seed file is already correct and will be overwritten idempotently.`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { status: 'published', copiedTo: seedPath };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { publishLearning };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* python-install.js — H-002b Python install path helpers.
|
|
4
|
+
*
|
|
5
|
+
* Pure helpers — no I/O. Caller (hone-cli.js installTestFramework
|
|
6
|
+
* dispatch) does the actual exec/write; we provide the policy
|
|
7
|
+
* (commands, detection, config block).
|
|
8
|
+
*
|
|
9
|
+
* Closes #54 deliverables 1+3 (the python config + detection +
|
|
10
|
+
* command + pyproject section). Deliverables 2 (installTestFramework
|
|
11
|
+
* branch) and 4 (CLAUDE.md substitution wiring) live in cli/hone-cli.js.
|
|
12
|
+
*
|
|
13
|
+
* 12th instance of pure-helpers + thin-CLI-shell pattern.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
// PYTHON_INSTALL_CONFIG — getInstallPackages('python') returns this
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
21
|
+
const PYTHON_INSTALL_CONFIG = {
|
|
22
|
+
unit: ['pytest', 'pytest-cov'],
|
|
23
|
+
e2e: ['pytest-playwright'],
|
|
24
|
+
testCmd: 'pytest',
|
|
25
|
+
testWatchCmd: 'pytest-watch',
|
|
26
|
+
e2eCmd: 'pytest tests/e2e',
|
|
27
|
+
configFile: 'pyproject.toml',
|
|
28
|
+
pkgManagerKind: 'python', // signals installTestFramework to branch
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
32
|
+
// detectPythonPackageManager — pure detection from filesystem signals
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
34
|
+
// Priority order (per issue spec §E1 + §5 detection rules):
|
|
35
|
+
// 1. Pipfile → pipenv (most specific)
|
|
36
|
+
// 2. pyproject.toml + poetry.lock → poetry
|
|
37
|
+
// 3. anything else → pip (default, includes requirements.txt)
|
|
38
|
+
function detectPythonPackageManager(repoRoot, fileExists) {
|
|
39
|
+
if (typeof fileExists !== 'function') return 'pip';
|
|
40
|
+
if (fileExists(path.join(repoRoot, 'Pipfile'))) return 'pipenv';
|
|
41
|
+
if (fileExists(path.join(repoRoot, 'pyproject.toml'))
|
|
42
|
+
&& fileExists(path.join(repoRoot, 'poetry.lock'))) {
|
|
43
|
+
return 'poetry';
|
|
44
|
+
}
|
|
45
|
+
return 'pip';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
49
|
+
// getPythonInstallCommand — exact command string per pkg manager
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
51
|
+
function getPythonInstallCommand(pkgMgr) {
|
|
52
|
+
switch (pkgMgr) {
|
|
53
|
+
case 'pipenv': return 'pipenv install --dev pytest pytest-cov';
|
|
54
|
+
case 'poetry': return 'poetry add --group dev pytest pytest-cov';
|
|
55
|
+
case 'pip':
|
|
56
|
+
default: return 'pip install --user pytest pytest-cov';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
61
|
+
// buildPyprojectTomlSection — TOML block for [tool.pytest.ini_options]
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
63
|
+
// Returns a string ready to append to pyproject.toml. Includes the
|
|
64
|
+
// minimal config that makes `pytest` discover tests under tests/
|
|
65
|
+
// without further configuration.
|
|
66
|
+
function buildPyprojectTomlSection() {
|
|
67
|
+
return [
|
|
68
|
+
'[tool.pytest.ini_options]',
|
|
69
|
+
'minversion = "7.0"',
|
|
70
|
+
'testpaths = ["tests"]',
|
|
71
|
+
'python_files = ["test_*.py", "*_test.py"]',
|
|
72
|
+
'python_classes = ["Test*"]',
|
|
73
|
+
'python_functions = ["test_*"]',
|
|
74
|
+
'addopts = "--strict-markers"',
|
|
75
|
+
'',
|
|
76
|
+
].join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
PYTHON_INSTALL_CONFIG,
|
|
81
|
+
detectPythonPackageManager,
|
|
82
|
+
getPythonInstallCommand,
|
|
83
|
+
buildPyprojectTomlSection,
|
|
84
|
+
};
|