@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,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
|
+
};
|