@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.
Files changed (60) hide show
  1. package/bin/hone.js +2 -0
  2. package/hone-cli.js +4006 -0
  3. package/lib/README.md +119 -0
  4. package/lib/adversarial-negative-lint.js +149 -0
  5. package/lib/audit.js +156 -0
  6. package/lib/auto-detect.js +213 -0
  7. package/lib/autofix-guardrails.js +124 -0
  8. package/lib/branch-protection.js +256 -0
  9. package/lib/ci-classifier.js +150 -0
  10. package/lib/ci-failures.js +173 -0
  11. package/lib/claude-md-tokens.js +71 -0
  12. package/lib/compliance-check.js +62 -0
  13. package/lib/config-augment.js +133 -0
  14. package/lib/config-update.js +70 -0
  15. package/lib/dependency-audit.js +108 -0
  16. package/lib/derive-domain.js +185 -0
  17. package/lib/doc-registry.js +63 -0
  18. package/lib/doctor-admin-merge.js +185 -0
  19. package/lib/doctor-bind-default.js +118 -0
  20. package/lib/doctor-docs.js +205 -0
  21. package/lib/doctor-placeholders.js +144 -0
  22. package/lib/doctor-skill-staleness.js +122 -0
  23. package/lib/domain-skill-template.md +114 -0
  24. package/lib/editor-detect.js +169 -0
  25. package/lib/fast-track-ratify.js +133 -0
  26. package/lib/git-helpers.js +109 -0
  27. package/lib/hook-templates/pre-commit.sh +54 -0
  28. package/lib/hook-templates/pre-push.sh +72 -0
  29. package/lib/install-hooks.js +205 -0
  30. package/lib/knowledge-graph.js +188 -0
  31. package/lib/learnings-audit.js +254 -0
  32. package/lib/learnings-parse.js +331 -0
  33. package/lib/learnings-sync.js +75 -0
  34. package/lib/mcp-detect.js +154 -0
  35. package/lib/metrics-collect.js +214 -0
  36. package/lib/overlay-merge.js +267 -0
  37. package/lib/performance-analyzer.js +142 -0
  38. package/lib/pipeline-config.js +83 -0
  39. package/lib/pipeline-status.js +207 -0
  40. package/lib/pipeline-validate.js +322 -0
  41. package/lib/platform-detect.js +86 -0
  42. package/lib/platform-discover.js +334 -0
  43. package/lib/publish-learning.js +160 -0
  44. package/lib/python-install.js +84 -0
  45. package/lib/refresh-check.js +67 -0
  46. package/lib/refresh-knowledge.js +360 -0
  47. package/lib/rule-resolver.js +146 -0
  48. package/lib/security-scanner.js +168 -0
  49. package/lib/setup-grounding.js +138 -0
  50. package/lib/skill-assertions.js +276 -0
  51. package/lib/skill-audit-render.js +158 -0
  52. package/lib/skill-audit.js +391 -0
  53. package/lib/stack-detect.js +170 -0
  54. package/lib/stack-paths.js +285 -0
  55. package/lib/story-classifier-extract.js +203 -0
  56. package/lib/story-classifier.js +282 -0
  57. package/lib/sync-overwrite.js +47 -0
  58. package/lib/synthetic-pipeline.js +299 -0
  59. package/lib/validate-metadata.js +175 -0
  60. package/package.json +41 -0
@@ -0,0 +1,214 @@
1
+ 'use strict';
2
+ /**
3
+ * metrics-collect.js — H-008 pure helpers for the `hone metrics collect`
4
+ * CLI command. No I/O — callers (hone-cli.js) do all filesystem reads,
5
+ * git/gh shells, file writes, and pass parsed inputs in.
6
+ *
7
+ * Same shape as cli/lib/pipeline-status.js (H-009) +
8
+ * cli/lib/auto-detect.js (H-018) + cli/lib/learnings-parse.js (H-035).
9
+ * 4th instance of the pure-helpers + thin-CLI-wrapper pattern.
10
+ *
11
+ * Closes #17 (sub-task (a) — CLI command). Sub-tasks (b) Code Reviewer
12
+ * agent prompt wiring and (c) CI gate are deferred to Phase 3.7+.
13
+ */
14
+
15
+ const yaml = require('js-yaml');
16
+
17
+ // ── Pipeline-metadata → metrics-schema name mapping (H-008/A1) ────
18
+ // Verified against docs/metrics/README.md and 16 existing pipeline
19
+ // metadata.yml files. step_3b is conditional (manual E2E mode only).
20
+ const PIPELINE_TO_METRICS_NAME = {
21
+ step_0: 'story_groomer',
22
+ step_1: 'implementation_planner',
23
+ step_2: 'unit_test_writer',
24
+ step_3a: 'e2e_qa_planner',
25
+ step_3b: 'e2e_test_spec_writer',
26
+ step_4: 'code_builder',
27
+ step_5: 'code_reviewer',
28
+ };
29
+
30
+ // ─────────────────────────────────────────────────────────────────────
31
+ // parseISODate — parse a YYYY-MM-DD string to a local-midnight Date
32
+ // ─────────────────────────────────────────────────────────────────────
33
+ // Avoids the `new Date('2026-04-27')` timezone trap (parses as UTC,
34
+ // which becomes the previous day in west-of-UTC locales).
35
+ function parseISODate(s) {
36
+ if (!s) return null;
37
+ const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
38
+ if (!m) return null;
39
+ return new Date(+m[1], +m[2] - 1, +m[3]);
40
+ }
41
+
42
+ // ─────────────────────────────────────────────────────────────────────
43
+ // businessDaysBetween — count weekdays (Mon-Fri) inclusive in a date range
44
+ // ─────────────────────────────────────────────────────────────────────
45
+ // Returns null if start is missing or unparseable.
46
+ // end defaults to today if missing.
47
+ function businessDaysBetween(startStr, endStr) {
48
+ if (!startStr) return null;
49
+ const start = parseISODate(startStr);
50
+ const end = endStr ? parseISODate(endStr) : new Date();
51
+ if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
52
+ return null;
53
+ }
54
+ if (end < start) return 0;
55
+
56
+ let days = 0;
57
+ // Iterate day-by-day inclusive on both ends
58
+ const cur = new Date(start.getFullYear(), start.getMonth(), start.getDate());
59
+ const last = new Date(end.getFullYear(), end.getMonth(), end.getDate());
60
+ while (cur <= last) {
61
+ const dow = cur.getDay(); // 0=Sun, 6=Sat
62
+ if (dow !== 0 && dow !== 6) days++;
63
+ cur.setDate(cur.getDate() + 1);
64
+ }
65
+ return days;
66
+ }
67
+
68
+ // ─────────────────────────────────────────────────────────────────────
69
+ // filterTestFiles — restrict a file list to recognizable test files
70
+ // ─────────────────────────────────────────────────────────────────────
71
+ // Matches: *.test.{js,ts,tsx,jsx,mjs,cjs} OR *.spec.{js,ts,...} anywhere
72
+ // under tests/, server/test/, etc. Conservative — a file is a "test"
73
+ // if its name matches a recognizable test/spec pattern.
74
+ function filterTestFiles(files) {
75
+ if (!Array.isArray(files)) return [];
76
+ const TEST_RE = /\.(?:test|spec)\.[jt]sx?$|\.(?:test|spec)\.(?:mjs|cjs)$/;
77
+ return files.filter(f => typeof f === 'string' && TEST_RE.test(f));
78
+ }
79
+
80
+ // ─────────────────────────────────────────────────────────────────────
81
+ // buildScorecard — pure transformer
82
+ // ─────────────────────────────────────────────────────────────────────
83
+ // inputs: {
84
+ // parsedPipelineMetadata: { storyId, branch, title, base, created, steps },
85
+ // artifactPresence: { step_0: bool, step_1: bool, ... } // filesystem truth
86
+ // gitInfo: { author? },
87
+ // prInfo: { pr_number? },
88
+ // learningsCount: number,
89
+ // filesChanged: string[],
90
+ // today: string (ISO date — for cycle_days computation)
91
+ // }
92
+ // returns: { story, branch, date, started_date, completed_date, author, steps, totals }
93
+ function buildScorecard(inputs) {
94
+ const meta = inputs.parsedPipelineMetadata || {};
95
+ const presence = inputs.artifactPresence || {};
96
+ const git = inputs.gitInfo || {};
97
+ const pr = inputs.prInfo || {};
98
+ const today = inputs.today;
99
+
100
+ // ── Header ────────────────────────────────────────────────
101
+ const startedDate = meta.created || null;
102
+ const hasPr = !!(pr && pr.pr_number);
103
+ // completed_date: when the PR was opened (proxy = step_5.completed if PR exists)
104
+ const completedDate = hasPr
105
+ ? ((meta.steps && meta.steps.step_5 && meta.steps.step_5.completed) || null)
106
+ : null;
107
+
108
+ // ── Per-step blocks ─────────────────────────────────────
109
+ const steps = {};
110
+ for (const [pipKey, metKey] of Object.entries(PIPELINE_TO_METRICS_NAME)) {
111
+ const stepMeta = (meta.steps && meta.steps[pipKey]) || null;
112
+ const fileExists = !!presence[pipKey];
113
+
114
+ // Skip step_3b if absent in metadata AND no artifact (it's conditional)
115
+ if (pipKey === 'step_3b' && !stepMeta && !fileExists) continue;
116
+
117
+ // Filesystem-as-source-of-truth (AC-5):
118
+ // metadata.completed + artifact-missing → 'not-run' (don't lie)
119
+ let status = 'not-run';
120
+ if (stepMeta && stepMeta.status === 'completed' && fileExists) {
121
+ status = 'completed';
122
+ }
123
+
124
+ const block = { status };
125
+ if (stepMeta && stepMeta.started) block.started_date = stepMeta.started;
126
+ if (stepMeta && stepMeta.gate_result) block.gate_result = stepMeta.gate_result;
127
+ // gate_attempts, cross_validation, test_results, findings → v2 enrichment
128
+
129
+ steps[metKey] = block;
130
+ }
131
+
132
+ // ── pre_commit / pre_push (automated, not in pipeline metadata) ───
133
+ // v1 records as not-run; agents (D1) or CI (D2) can fill later
134
+ steps.pre_commit = { status: 'not-run' };
135
+ steps.pre_push = { status: 'not-run' };
136
+
137
+ // ── pr_opened block (when PR exists) ──────────────────
138
+ if (hasPr) {
139
+ steps.pr_opened = {
140
+ status: 'completed',
141
+ pr_number: pr.pr_number,
142
+ };
143
+ if (completedDate) steps.pr_opened.started_date = completedDate;
144
+ }
145
+
146
+ // ── Totals ─────────────────────────────────────────────
147
+ const stepsCompleted = Object.values(steps)
148
+ .filter(s => s.status === 'completed').length;
149
+
150
+ const filesChanged = Array.isArray(inputs.filesChanged) ? inputs.filesChanged : [];
151
+ const testsWritten = filterTestFiles(filesChanged).length;
152
+
153
+ const cycleDays = startedDate && today
154
+ ? Math.max(0, Math.floor((new Date(today) - new Date(startedDate)) / 86400000))
155
+ : null;
156
+ const businessCycleDays = startedDate
157
+ ? businessDaysBetween(startedDate, today)
158
+ : null;
159
+
160
+ const totals = {
161
+ steps_completed: stepsCompleted,
162
+ learnings_captured: inputs.learningsCount || 0,
163
+ files_changed: filesChanged.length,
164
+ tests_written: testsWritten,
165
+ pipeline_result: hasPr ? 'completed' : 'in-progress',
166
+ abandoned_at_step: null,
167
+ cycle_days: cycleDays,
168
+ business_cycle_days: businessCycleDays,
169
+ // gate_pass_rate, total_gate_attempts, cross_validation_catches
170
+ // → v2 enrichment (omitted in v1 to avoid emitting fake zeros)
171
+ };
172
+
173
+ // ── Final scorecard ────────────────────────────────────
174
+ const scorecard = {
175
+ story: meta.storyId || null,
176
+ branch: meta.branch || null,
177
+ date: today || null,
178
+ started_date: startedDate,
179
+ completed_date: completedDate,
180
+ };
181
+ if (git.author) scorecard.author = git.author;
182
+ scorecard.steps = steps;
183
+ scorecard.totals = totals;
184
+ return scorecard;
185
+ }
186
+
187
+ // ─────────────────────────────────────────────────────────────────────
188
+ // renderScorecardYaml — render scorecard object to YAML string
189
+ // ─────────────────────────────────────────────────────────────────────
190
+ // Adds a header comment so adopters know it was bootstrapped by H-008.
191
+ function renderScorecardYaml(scorecard) {
192
+ const header = [
193
+ '# Pipeline metrics scorecard',
194
+ '# Bootstrapped by `hone metrics collect` (H-008).',
195
+ '# Schema: docs/metrics/README.md',
196
+ '# Agents may append richer fields (gate_attempts, cross_validation,',
197
+ '# test_results, findings) per the "Who Writes What" protocol.',
198
+ '',
199
+ ].join('\n');
200
+ const body = yaml.dump(scorecard, {
201
+ lineWidth: 120,
202
+ noRefs: true,
203
+ sortKeys: false,
204
+ });
205
+ return header + body;
206
+ }
207
+
208
+ module.exports = {
209
+ PIPELINE_TO_METRICS_NAME,
210
+ buildScorecard,
211
+ businessDaysBetween,
212
+ filterTestFiles,
213
+ renderScorecardYaml,
214
+ };
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+ /**
3
+ * overlay-merge.js — H-084 layer adopter overlays onto canonical defaults.
4
+ *
5
+ * Adopter customizations (rule additions like E24-A regression policy) live in
6
+ * `.hone-local/rule-overlays/<step>.<rule>.md`. Each overlay file is a chunk
7
+ * of Markdown PLUS optional YAML front-matter declaring where to insert:
8
+ *
9
+ * ---
10
+ * target_step: code-reviewer
11
+ * rule_id: E24-A-regression-policy
12
+ * insert: after-section "Hard Rules" # or "before-section X" or "append"
13
+ * ---
14
+ * ## Custom rule
15
+ * ... markdown content ...
16
+ *
17
+ * `mergeOverlays(defaultText, overlays)` returns the merged result.
18
+ *
19
+ * Pure helper. Caller does I/O (reads defaults, walks .hone-local/, writes
20
+ * projection output).
21
+ *
22
+ * Closes #84 (M scope). L scope: hone init --upgrade integration; hone
23
+ * migrate-overlays; CUSTOMIZATION.md docs.
24
+ */
25
+
26
+ /**
27
+ * Parse YAML front-matter from a Markdown overlay file.
28
+ * Minimal: scans for `---\n...\n---\n` at the top.
29
+ *
30
+ * @param {string} text
31
+ * @returns {{ frontMatter: object, body: string }}
32
+ */
33
+ function parseFrontMatter(text) {
34
+ if (typeof text !== 'string') return { frontMatter: {}, body: '' };
35
+ const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
36
+ if (!m) return { frontMatter: {}, body: text };
37
+ const frontMatter = {};
38
+ for (const line of m[1].split('\n')) {
39
+ const kv = line.match(/^([a-z_]+):\s*(.*)$/i);
40
+ if (!kv) continue;
41
+ let value = kv[2].trim();
42
+ // Strip surrounding quotes
43
+ if (/^["'].*["']$/.test(value)) value = value.slice(1, -1);
44
+ frontMatter[kv[1].trim()] = value;
45
+ }
46
+ return { frontMatter, body: m[2].replace(/^\n+/, '') };
47
+ }
48
+
49
+ /**
50
+ * Merge an array of overlays onto a canonical default text.
51
+ *
52
+ * Insertion semantics (front-matter `insert` field):
53
+ * - `append` (default): overlay body appended at end
54
+ * - `prepend`: overlay body prepended at top (after any title)
55
+ * - `after-section "X"`: insert AFTER the heading line `## X` (and any
56
+ * content before next `## ` heading)
57
+ * - `before-section "X"`: insert BEFORE the heading line `## X`
58
+ *
59
+ * Order: overlays are applied in the order provided. Each overlay's rule_id
60
+ * is inserted as a marker comment so a downstream drift guard can verify
61
+ * the rule landed: `<!-- overlay:RULE-ID -->`.
62
+ *
63
+ * @param {string} defaultText - the canonical default file content
64
+ * @param {Array<{path?: string, content: string}>} overlays - overlay files
65
+ * @param {object} [opts]
66
+ * @param {'error'|'skip'|'both'} [opts.onConflict='error'] - #118 conflict mode:
67
+ * - 'error' (default): conflict adds entry to `errors`; overlay is NOT applied
68
+ * - 'skip': conflict adds entry to `conflicts` (warning); overlay is skipped silently
69
+ * - 'both': conflict adds entry to `conflicts`; overlay IS applied (allows duplicate
70
+ * headings to coexist with marker comments distinguishing them)
71
+ * Adopters can also bypass conflict detection per-overlay by setting
72
+ * `insert: replace-section "Name"` in the overlay's front-matter.
73
+ * @returns {{ merged: string, applied: Array<{ruleId, insertSpec, ok}>, errors: string[],
74
+ * conflicts: Array<{heading, sources}> }}
75
+ */
76
+ function mergeOverlays(defaultText, overlays, opts) {
77
+ if (typeof defaultText !== 'string') {
78
+ return { merged: '', applied: [], errors: ['defaultText must be a string'], conflicts: [] };
79
+ }
80
+ if (!Array.isArray(overlays)) overlays = [];
81
+ // Defensive: opts may be null / undefined / non-object — treat as empty
82
+ if (opts === null || opts === undefined || typeof opts !== 'object') opts = {};
83
+
84
+ // #118: conflict-mode option (default 'error' surfaces conflicts as merge failures)
85
+ const onConflict = opts.onConflict || 'error';
86
+ if (!['error', 'skip', 'both'].includes(onConflict)) {
87
+ return {
88
+ merged: defaultText,
89
+ applied: [],
90
+ errors: [`invalid opts.onConflict "${onConflict}" — must be 'error' | 'skip' | 'both'`],
91
+ conflicts: [],
92
+ };
93
+ }
94
+
95
+ let result = defaultText;
96
+ const applied = [];
97
+ const errors = [];
98
+ const conflicts = []; // #118: collisions detected during merge
99
+
100
+ // #118: track headings already defined (canonical + already-merged overlays)
101
+ // to detect collisions before each overlay applies.
102
+ const definedHeadings = collectH2Headings(defaultText);
103
+
104
+ for (let i = 0; i < overlays.length; i++) {
105
+ const overlay = overlays[i];
106
+ if (!overlay || typeof overlay.content !== 'string') {
107
+ errors.push(`overlay[${i}] missing content`);
108
+ continue;
109
+ }
110
+ const { frontMatter, body } = parseFrontMatter(overlay.content);
111
+ const ruleId = frontMatter.rule_id || frontMatter.ruleId || `overlay-${i}`;
112
+ const insert = frontMatter.insert || 'append';
113
+ const marker = `<!-- overlay:${ruleId} -->`;
114
+ const wrappedBody = `${marker}\n${body.trimEnd()}\n`;
115
+
116
+ // #118: collision detection (skip when insert spec is explicitly replace-section,
117
+ // which signals "I'm replacing, not adding")
118
+ const isReplaceMode = /^replace-section\s+["']/.test(insert);
119
+ if (!isReplaceMode) {
120
+ const overlayHeadings = collectH2Headings(body);
121
+ const collisions = overlayHeadings.filter((h) => definedHeadings.includes(h));
122
+ if (collisions.length > 0) {
123
+ for (const heading of collisions) {
124
+ conflicts.push({
125
+ heading,
126
+ sources: ['canonical or earlier overlay', `overlay ${ruleId}`],
127
+ ruleId,
128
+ });
129
+ }
130
+ if (onConflict === 'error') {
131
+ errors.push(`overlay ${ruleId}: heading collision on [${collisions.join(', ')}] — use insert: replace-section "..." to resolve, or pass opts.onConflict='skip'/'both'`);
132
+ applied.push({ ruleId, insertSpec: insert, ok: false });
133
+ continue; // don't apply; default is fail-fast
134
+ }
135
+ if (onConflict === 'skip') {
136
+ applied.push({ ruleId, insertSpec: insert, ok: false });
137
+ continue; // skip overlay
138
+ }
139
+ // onConflict === 'both' falls through to apply normally
140
+ }
141
+ }
142
+
143
+ let inserted = false;
144
+
145
+ if (insert === 'append') {
146
+ result = result.replace(/\n*$/, '\n\n') + wrappedBody;
147
+ inserted = true;
148
+ } else if (insert === 'prepend') {
149
+ // Insert AFTER the first H1 (title line) if present, otherwise at top
150
+ const titleMatch = result.match(/^(# [^\n]+\n)/);
151
+ if (titleMatch) {
152
+ result = result.replace(titleMatch[0], titleMatch[0] + '\n' + wrappedBody + '\n');
153
+ } else {
154
+ result = wrappedBody + '\n' + result;
155
+ }
156
+ inserted = true;
157
+ } else if (/^after-section\s+["']?(.+?)["']?$/.test(insert)) {
158
+ const sectionName = insert.match(/^after-section\s+["']?(.+?)["']?$/)[1];
159
+ // Find `## SectionName` heading; insert wrappedBody before next `## ` or end
160
+ const sectionRe = new RegExp(`(^## ${escapeRegex(sectionName)}\\s*\\n[\\s\\S]*?)(?=\\n## |$)`, 'm');
161
+ const m = result.match(sectionRe);
162
+ if (m) {
163
+ const insertAt = m.index + m[0].length;
164
+ result = result.slice(0, insertAt) + '\n' + wrappedBody + '\n' + result.slice(insertAt);
165
+ inserted = true;
166
+ } else {
167
+ errors.push(`overlay ${ruleId}: target section "${sectionName}" not found in default`);
168
+ }
169
+ } else if (/^before-section\s+["']?(.+?)["']?$/.test(insert)) {
170
+ const sectionName = insert.match(/^before-section\s+["']?(.+?)["']?$/)[1];
171
+ const sectionRe = new RegExp(`^## ${escapeRegex(sectionName)}`, 'm');
172
+ const m = result.match(sectionRe);
173
+ if (m) {
174
+ result = result.slice(0, m.index) + wrappedBody + '\n' + result.slice(m.index);
175
+ inserted = true;
176
+ } else {
177
+ errors.push(`overlay ${ruleId}: target section "${sectionName}" not found in default`);
178
+ }
179
+ } else if (/^replace-section\s+["']?(.+?)["']?$/.test(insert)) {
180
+ // #118: replace-section directive — explicit collision-resolution mode
181
+ // The overlay body REPLACES the named section's content (heading + body
182
+ // until next ## or EOF). Adopter has signaled they intend to replace.
183
+ // Regex notes:
184
+ // - No /m flag (so $ matches end-of-string only, not end-of-line)
185
+ // - Match `## SectionName\n` then everything up to (but not including)
186
+ // the next `\n## ` heading OR end-of-string
187
+ const sectionName = insert.match(/^replace-section\s+["']?(.+?)["']?$/)[1];
188
+ const sectionRe = new RegExp(
189
+ `## ${escapeRegex(sectionName)}\\s*\\n[\\s\\S]*?(?=\\n## |$(?![\\s\\S]))`,
190
+ );
191
+ const m = result.match(sectionRe);
192
+ if (m) {
193
+ result = result.slice(0, m.index) + wrappedBody + result.slice(m.index + m[0].length);
194
+ inserted = true;
195
+ } else {
196
+ errors.push(`overlay ${ruleId}: replace-section target "${sectionName}" not found in default (cannot replace what doesn't exist)`);
197
+ }
198
+ } else {
199
+ errors.push(`overlay ${ruleId}: unrecognized insert spec "${insert}" (valid: append | prepend | after-section "X" | before-section "X" | replace-section "X")`);
200
+ }
201
+
202
+ applied.push({ ruleId, insertSpec: insert, ok: inserted });
203
+
204
+ // #118: track newly-added headings so subsequent overlays detect intra-overlay collisions
205
+ if (inserted) {
206
+ const newHeadings = collectH2Headings(body);
207
+ for (const h of newHeadings) {
208
+ if (!definedHeadings.includes(h)) definedHeadings.push(h);
209
+ }
210
+ }
211
+ }
212
+
213
+ return { merged: result, applied, errors, conflicts };
214
+ }
215
+
216
+ function escapeRegex(s) {
217
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
218
+ }
219
+
220
+ /**
221
+ * #118: extract level-2 headings (`## Heading`) from markdown content.
222
+ * Used by mergeOverlays to detect heading collisions.
223
+ *
224
+ * @param {string} text
225
+ * @returns {string[]} array of heading text (without `## ` prefix), trimmed
226
+ */
227
+ function collectH2Headings(text) {
228
+ if (typeof text !== 'string') return [];
229
+ const out = [];
230
+ const re = /^##\s+(.+?)\s*$/gm;
231
+ let m;
232
+ while ((m = re.exec(text)) !== null) {
233
+ const heading = m[1].trim();
234
+ // Skip H3+ (matched as part of multi-line context) — guard against false matches
235
+ if (heading.startsWith('#')) continue;
236
+ out.push(heading);
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /**
242
+ * Verify each overlay's rule-id marker appears in the merged output.
243
+ * Used as a drift-guard check.
244
+ *
245
+ * @param {string} mergedText
246
+ * @param {Array<{ruleId: string}>} applied - from mergeOverlays
247
+ * @returns {{ allPresent: boolean, missing: string[] }}
248
+ */
249
+ function verifyOverlayMarkers(mergedText, applied) {
250
+ if (typeof mergedText !== 'string' || !Array.isArray(applied)) {
251
+ return { allPresent: false, missing: [] };
252
+ }
253
+ const missing = [];
254
+ for (const a of applied) {
255
+ if (!a || !a.ok) continue; // skip ones that failed to insert
256
+ const marker = `<!-- overlay:${a.ruleId} -->`;
257
+ if (!mergedText.includes(marker)) missing.push(a.ruleId);
258
+ }
259
+ return { allPresent: missing.length === 0, missing };
260
+ }
261
+
262
+ module.exports = {
263
+ parseFrontMatter,
264
+ mergeOverlays,
265
+ verifyOverlayMarkers,
266
+ collectH2Headings, // #118: exposed for tests + adopter tooling
267
+ };
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+ /**
3
+ * performance-analyzer.js — HC-028-new + HC-029 static analysis for
4
+ * performance anti-patterns: N+1 queries, unbounded queries, sync blocking,
5
+ * plus platform-specific governor/governance rules.
6
+ *
7
+ * Pure helper — no I/O. Mirrors security-scanner.js pattern exactly.
8
+ *
9
+ * Architecture: docs/architecture/master-roadmap.md (Epic 3)
10
+ */
11
+
12
+ // ── Core Rules ─────────────────────────────────────────────────
13
+
14
+ const QUERY_PATTERNS = [
15
+ {
16
+ id: 'query-in-loop',
17
+ pattern: /for\s*\([\s\S]{0,300}(?:query|select|find|fetch|get)\s*\(/i,
18
+ severity: 'HIGH',
19
+ message: 'Database query inside a loop (N+1 pattern). Batch the query outside the loop.',
20
+ },
21
+ {
22
+ id: 'unbounded-select',
23
+ pattern: /(?:SELECT\s+\*?\s+FROM\s+\w+)(?![\s\S]{0,100}(?:LIMIT|WHERE|TOP)\b)/i,
24
+ severity: 'MEDIUM',
25
+ message: 'Unbounded SELECT without LIMIT or WHERE. Add pagination or filtering.',
26
+ },
27
+ ];
28
+
29
+ const BLOCKING_PATTERNS = [
30
+ {
31
+ id: 'sync-blocking',
32
+ pattern: /(?:readFileSync|writeFileSync|execSync|spawnSync)\s*\(/,
33
+ severity: 'MEDIUM',
34
+ message: 'Synchronous blocking call. Use async alternative in request handlers.',
35
+ },
36
+ ];
37
+
38
+ // ── Salesforce Governor Rules (HC-029) ─────────────────────────
39
+
40
+ const SALESFORCE_PERF_RULES = [
41
+ {
42
+ id: 'sf-dml-in-loop',
43
+ pattern: /for\s*\([\s\S]{0,200}(?:insert|update|delete|upsert)\s+/i,
44
+ severity: 'HIGH',
45
+ message: 'DML inside loop — governor limit (150 DML/transaction). Collect and execute outside loop.',
46
+ },
47
+ {
48
+ id: 'sf-unbounded-soql',
49
+ pattern: /\[SELECT\s+[\s\S]{0,200}FROM\s+\w+(?![\s\S]{0,100}LIMIT\b)/i,
50
+ severity: 'MEDIUM',
51
+ message: 'SOQL without LIMIT — may return more records than expected. Add LIMIT clause.',
52
+ },
53
+ ];
54
+
55
+ // ── NetSuite Governance Rules (HC-029) ─────────────────────────
56
+
57
+ const NETSUITE_PERF_RULES = [
58
+ {
59
+ id: 'ns-load-in-loop',
60
+ pattern: /for\s*\([\s\S]{0,300}record\.load\s*\(/i,
61
+ severity: 'HIGH',
62
+ message: 'record.load() inside loop — governance unit intensive. Use search or lookupFields instead.',
63
+ },
64
+ {
65
+ id: 'ns-unbounded-search',
66
+ pattern: /search\.create\s*\([\s\S]{0,300}\.run\s*\(\)/,
67
+ severity: 'MEDIUM',
68
+ message: 'Search without maxResults or paging. Use runPaged() or set maxResults for bounded results.',
69
+ },
70
+ ];
71
+
72
+ const PLATFORM_RULES = {
73
+ salesforce: SALESFORCE_PERF_RULES,
74
+ netsuite: NETSUITE_PERF_RULES,
75
+ };
76
+
77
+ // ── Analyzer ───────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Analyze files for performance anti-patterns.
81
+ *
82
+ * @param {object} opts
83
+ * @param {object} opts.files — { path: content } map
84
+ * @param {string} [opts.platform] — 'salesforce' | 'netsuite' | etc.
85
+ * @returns {{ findings: Array<{file, line, rule, severity, message}>, summary: string }}
86
+ */
87
+ function analyzeFiles(opts = {}) {
88
+ const files = opts.files || {};
89
+ const platform = opts.platform || null;
90
+ const findings = [];
91
+
92
+ const rules = [...QUERY_PATTERNS, ...BLOCKING_PATTERNS];
93
+ if (platform && PLATFORM_RULES[platform]) {
94
+ rules.push(...PLATFORM_RULES[platform]);
95
+ }
96
+
97
+ for (const [filePath, content] of Object.entries(files)) {
98
+ if (typeof content !== 'string') continue;
99
+
100
+ const lines = content.split('\n');
101
+ for (let i = 0; i < lines.length; i++) {
102
+ const line = lines[i];
103
+
104
+ // Skip comments
105
+ if (/^\s*(\/\/|#|--|\/\*|\*)/.test(line)) continue;
106
+
107
+ for (const rule of rules) {
108
+ // Some patterns need multi-line context (e.g., for-loop with query inside)
109
+ const context = lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 3)).join('\n');
110
+
111
+ if (rule.pattern.test(line) || (rule.id.includes('in-loop') && rule.pattern.test(context))) {
112
+ // Avoid duplicate: only report on the first matching line in context
113
+ if (rule.id.includes('in-loop') && !rule.pattern.test(line) && i > 0) continue;
114
+
115
+ findings.push({
116
+ file: filePath,
117
+ line: i + 1,
118
+ rule: rule.id,
119
+ severity: rule.severity,
120
+ message: rule.message,
121
+ });
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ const high = findings.filter(f => f.severity === 'HIGH').length;
128
+ const medium = findings.filter(f => f.severity === 'MEDIUM').length;
129
+ const low = findings.filter(f => f.severity === 'LOW').length;
130
+ const summary = findings.length === 0
131
+ ? 'No performance findings.'
132
+ : `${findings.length} finding(s): ${high} HIGH, ${medium} MEDIUM, ${low} LOW.`;
133
+
134
+ return { findings, summary };
135
+ }
136
+
137
+ module.exports = {
138
+ analyzeFiles,
139
+ QUERY_PATTERNS,
140
+ BLOCKING_PATTERNS,
141
+ PLATFORM_RULES,
142
+ };