@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,173 @@
1
+ 'use strict';
2
+ /**
3
+ * ci-failures.js — H-010 CI failures feedback loop helpers.
4
+ *
5
+ * Pure helpers — no I/O. Caller (hone-cli.js) reads/writes the jsonl file.
6
+ *
7
+ * Storage shape (per issue #19): .github/learnings/ci-failures.jsonl
8
+ * One JSON object per line (NDJSON).
9
+ * Required fields: tool, rule, timestamp.
10
+ * Optional: file, line, message, story_id, severity, category.
11
+ *
12
+ * Closes #19 parts 1+2 (record + summarize). Parts 3 (auto-derive) and
13
+ * 4 (server round-trip = #22 H-013) deferred.
14
+ *
15
+ * 7th instance of pure-helpers + thin-CLI-shell pattern in cli/lib/
16
+ * after auto-detect.js (H-018), learnings-parse.js (H-035),
17
+ * pipeline-status.js (H-009), metrics-collect.js (H-008),
18
+ * config-update.js (H-021), ci-classifier.js (H-061).
19
+ */
20
+
21
+ // ─────────────────────────────────────────────────────────────────────
22
+ // appendFailure — append one NDJSON entry to existing text
23
+ // ─────────────────────────────────────────────────────────────────────
24
+ // Returns new text (caller writes to disk). Stamps timestamp if absent.
25
+ function appendFailure(existingText, entry) {
26
+ if (typeof existingText !== 'string') existingText = '';
27
+ if (!entry || typeof entry !== 'object') return existingText;
28
+
29
+ const stamped = {
30
+ timestamp: entry.timestamp || new Date().toISOString(),
31
+ ...entry,
32
+ };
33
+ // Ensure timestamp is at the front for readability when grep'd
34
+ const ordered = { timestamp: stamped.timestamp };
35
+ for (const [k, v] of Object.entries(stamped)) {
36
+ if (k !== 'timestamp') ordered[k] = v;
37
+ }
38
+ const line = JSON.stringify(ordered);
39
+
40
+ // Ensure existing text ends with newline before appending
41
+ const sep = existingText.length > 0 && !existingText.endsWith('\n') ? '\n' : '';
42
+ return existingText + sep + line + '\n';
43
+ }
44
+
45
+ // ─────────────────────────────────────────────────────────────────────
46
+ // loadFailures — parse NDJSON; skip malformed lines gracefully (E2)
47
+ // ─────────────────────────────────────────────────────────────────────
48
+ // Returns { failures: FailureEntry[], malformedCount: number }.
49
+ function loadFailures(text) {
50
+ if (typeof text !== 'string' || text.length === 0) {
51
+ return { failures: [], malformedCount: 0 };
52
+ }
53
+ const failures = [];
54
+ let malformedCount = 0;
55
+ for (const raw of text.split('\n')) {
56
+ const line = raw.trim();
57
+ if (!line) continue;
58
+ try {
59
+ const parsed = JSON.parse(line);
60
+ if (parsed && typeof parsed === 'object') {
61
+ failures.push(parsed);
62
+ } else {
63
+ malformedCount++;
64
+ }
65
+ } catch {
66
+ malformedCount++;
67
+ }
68
+ }
69
+ return { failures, malformedCount };
70
+ }
71
+
72
+ // ─────────────────────────────────────────────────────────────────────
73
+ // groupByToolRule — aggregate by `${tool}:${rule}` key
74
+ // ─────────────────────────────────────────────────────────────────────
75
+ function groupByToolRule(failures) {
76
+ const out = new Map();
77
+ for (const f of failures || []) {
78
+ const key = `${f.tool || 'unknown'}:${f.rule || 'unknown'}`;
79
+ if (!out.has(key)) out.set(key, []);
80
+ out.get(key).push(f);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // ─────────────────────────────────────────────────────────────────────
86
+ // findRecurring — groups with count >= threshold, optionally within sinceDays
87
+ // ─────────────────────────────────────────────────────────────────────
88
+ // Returns { key, count, latestTimestamp, entries }[] sorted desc by count.
89
+ function findRecurring(failures, opts = {}) {
90
+ const threshold = opts.threshold || 3;
91
+ const sinceDays = opts.sinceDays;
92
+
93
+ let pool = failures || [];
94
+ if (sinceDays != null) {
95
+ const cutoff = Date.now() - sinceDays * 86400000;
96
+ pool = pool.filter(f => {
97
+ if (!f.timestamp) return false;
98
+ const t = new Date(f.timestamp).getTime();
99
+ return Number.isFinite(t) && t >= cutoff;
100
+ });
101
+ }
102
+
103
+ const groups = groupByToolRule(pool);
104
+ const result = [];
105
+ for (const [key, entries] of groups.entries()) {
106
+ if (entries.length >= threshold) {
107
+ const latestTimestamp = entries
108
+ .map(e => e.timestamp)
109
+ .filter(Boolean)
110
+ .sort()
111
+ .pop() || null;
112
+ result.push({
113
+ key,
114
+ count: entries.length,
115
+ latestTimestamp,
116
+ entries,
117
+ });
118
+ }
119
+ }
120
+ return result.sort((a, b) => b.count - a.count);
121
+ }
122
+
123
+ // ─────────────────────────────────────────────────────────────────────
124
+ // summarize — human-readable summary string
125
+ // ─────────────────────────────────────────────────────────────────────
126
+ function summarize(failures, opts = {}) {
127
+ const threshold = opts.threshold || 3;
128
+ const sinceDays = opts.sinceDays || 30;
129
+
130
+ const lines = [];
131
+ lines.push(`CI failures summary`);
132
+ lines.push(`==================`);
133
+ lines.push(`Total entries: ${failures.length}`);
134
+
135
+ const groups = groupByToolRule(failures);
136
+ lines.push(`Distinct tool:rule pairs: ${groups.size}`);
137
+ lines.push('');
138
+
139
+ // All-time top groups
140
+ const sorted = [...groups.entries()]
141
+ .map(([key, entries]) => ({ key, count: entries.length }))
142
+ .sort((a, b) => b.count - a.count)
143
+ .slice(0, 10);
144
+
145
+ if (sorted.length > 0) {
146
+ lines.push(`Top tool:rule pairs (all-time):`);
147
+ for (const g of sorted) {
148
+ lines.push(` ${g.count.toString().padStart(4)} × ${g.key}`);
149
+ }
150
+ lines.push('');
151
+ }
152
+
153
+ const recurring = findRecurring(failures, { threshold, sinceDays });
154
+ if (recurring.length > 0) {
155
+ lines.push(`Recurring patterns (≥${threshold} occurrences in last ${sinceDays} days):`);
156
+ for (const r of recurring) {
157
+ lines.push(` ${r.count.toString().padStart(4)} × ${r.key} (latest: ${r.latestTimestamp || 'unknown'})`);
158
+ }
159
+ lines.push('');
160
+ lines.push(`Consider adding patterns from the above to .github/skills/ via \`hone derive\`.`);
161
+ } else {
162
+ lines.push(`No recurring patterns (threshold ${threshold} in last ${sinceDays} days).`);
163
+ }
164
+ return lines.join('\n');
165
+ }
166
+
167
+ module.exports = {
168
+ appendFailure,
169
+ loadFailures,
170
+ groupByToolRule,
171
+ findRecurring,
172
+ summarize,
173
+ };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+ /**
3
+ * claude-md-tokens.js — H-002b CLAUDE.md template token substitution.
4
+ *
5
+ * Pure helpers — no I/O. Caller (hone-cli.js setup command) reads the
6
+ * template, calls substituteClaudeMdTokens, writes the result.
7
+ *
8
+ * Closes #54 deliverable 4 (CLAUDE.md `{{TEST_COMMAND}}` substitution).
9
+ *
10
+ * 13th instance of pure-helpers + thin-CLI-shell pattern.
11
+ */
12
+
13
+ // ─────────────────────────────────────────────────────────────────────
14
+ // CLAUDE_MD_TOKENS — canonical token list (extend in v2 stories)
15
+ // ─────────────────────────────────────────────────────────────────────
16
+ const CLAUDE_MD_TOKENS = ['TEST_COMMAND', 'LINT_COMMAND', 'PRIMARY_STACK'];
17
+
18
+ // ─────────────────────────────────────────────────────────────────────
19
+ // STACK_VARS — per-stack defaults
20
+ // ─────────────────────────────────────────────────────────────────────
21
+ // Returns the values for {{TEST_COMMAND}} / {{LINT_COMMAND}} /
22
+ // {{PRIMARY_STACK}} per detected primary stack. Falls back to node
23
+ // for unknown stacks (graceful — see E5).
24
+ const STACK_VARS = {
25
+ python: { TEST_COMMAND: 'pytest', LINT_COMMAND: 'ruff check', PRIMARY_STACK: 'python' },
26
+ node: { TEST_COMMAND: 'npm test', LINT_COMMAND: 'npm run lint', PRIMARY_STACK: 'node' },
27
+ 'react-vite': { TEST_COMMAND: 'npm test', LINT_COMMAND: 'npm run lint', PRIMARY_STACK: 'react-vite' },
28
+ react: { TEST_COMMAND: 'npm test', LINT_COMMAND: 'npm run lint', PRIMARY_STACK: 'react' },
29
+ nextjs: { TEST_COMMAND: 'npm test', LINT_COMMAND: 'npm run lint', PRIMARY_STACK: 'nextjs' },
30
+ nestjs: { TEST_COMMAND: 'npm test', LINT_COMMAND: 'npm run lint', PRIMARY_STACK: 'nestjs' },
31
+ java: { TEST_COMMAND: 'mvn test', LINT_COMMAND: 'mvn checkstyle:check', PRIMARY_STACK: 'java' },
32
+ dotnet: { TEST_COMMAND: 'dotnet test', LINT_COMMAND: 'dotnet format --verify-no-changes', PRIMARY_STACK: 'dotnet' },
33
+ salesforce: { TEST_COMMAND: 'sfdx force:lightning:lwc:test:run', LINT_COMMAND: 'eslint **/*.js', PRIMARY_STACK: 'salesforce' },
34
+ };
35
+
36
+ // ─────────────────────────────────────────────────────────────────────
37
+ // getClaudeMdVarsForStack — returns the {TEST,LINT,STACK} object
38
+ // ─────────────────────────────────────────────────────────────────────
39
+ function getClaudeMdVarsForStack(stack) {
40
+ if (stack && STACK_VARS[stack]) return { ...STACK_VARS[stack] };
41
+ // E5 graceful fallback: unknown stack → node defaults
42
+ return { ...STACK_VARS.node };
43
+ }
44
+
45
+ // ─────────────────────────────────────────────────────────────────────
46
+ // substituteClaudeMdTokens — replace {{KNOWN_TOKEN}} occurrences
47
+ // ─────────────────────────────────────────────────────────────────────
48
+ // Only replaces tokens that have a value in the vars object.
49
+ // Unknown {{TOKEN}}s are left intact (E4 — future-proof for new
50
+ // stories that may add tokens not yet in this list).
51
+ function substituteClaudeMdTokens(template, vars = {}) {
52
+ if (typeof template !== 'string') return template;
53
+ let out = template;
54
+ for (const [key, value] of Object.entries(vars)) {
55
+ if (value == null) continue;
56
+ const re = new RegExp(`\\{\\{${escapeRegex(key)}\\}\\}`, 'g');
57
+ out = out.replace(re, String(value));
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function escapeRegex(s) {
63
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
64
+ }
65
+
66
+ module.exports = {
67
+ CLAUDE_MD_TOKENS,
68
+ STACK_VARS,
69
+ getClaudeMdVarsForStack,
70
+ substituteClaudeMdTokens,
71
+ };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+ /**
3
+ * compliance-check.js — H-011 pipeline-compliance pre-commit check.
4
+ *
5
+ * Pure helper — no I/O at the helper layer. Caller (hone-cli.js) does
6
+ * git symbolic-ref + filesystem checks; passes results in.
7
+ *
8
+ * Closes #20 sub-tasks (a) CLI command + (b) docs example. Sub-task
9
+ * (c) `hone setup --pre-commit` flag deferred to a follow-up.
10
+ *
11
+ * Reuses H-009's STORY_ID_PATTERN regex for branch → STORY-ID
12
+ * extraction (DRY — same canonical pattern as H-005 substitutes into
13
+ * ai-review.yml at install time).
14
+ *
15
+ * 11th instance of pure-helpers + thin-CLI-shell pattern.
16
+ */
17
+
18
+ const path = require('path');
19
+ const { extractStoryIdFromBranch } = require('./pipeline-status');
20
+
21
+ // ─────────────────────────────────────────────────────────────────────
22
+ // evaluateCompliance — pure compliance evaluator
23
+ // ─────────────────────────────────────────────────────────────────────
24
+ // inputs:
25
+ // branch: string | null (current git branch; null/empty for detached HEAD)
26
+ // pipelineRoot: string (path to .github/pipeline/, used to compute `expected`)
27
+ // fileExists: (path) => bool (caller's filesystem checker — testable)
28
+ //
29
+ // returns: { compliant, reason, storyId, expected? }
30
+ // reasons:
31
+ // 'no-story-id' → branch has no STORY-ID pattern (graceful pass)
32
+ // 'no-pipeline-dir' → .github/pipeline/ missing (graceful pass — fresh repo)
33
+ // 'ok' → STORY-ID present + step-0-grooming.md exists
34
+ // 'missing-grooming' → STORY-ID present + step-0-grooming.md missing (FAIL)
35
+ function evaluateCompliance({ branch, pipelineRoot, fileExists } = {}) {
36
+ // 1. Empty/null branch → detached HEAD or no git → graceful pass
37
+ if (!branch || typeof branch !== 'string' || branch.length === 0) {
38
+ return { compliant: true, reason: 'no-story-id', storyId: null };
39
+ }
40
+
41
+ // 2. Branch has no STORY-ID pattern → graceful pass (chore branches OK)
42
+ const storyId = extractStoryIdFromBranch(branch);
43
+ if (!storyId) {
44
+ return { compliant: true, reason: 'no-story-id', storyId: null };
45
+ }
46
+
47
+ // 3. Pipeline dir missing → fresh repo, hasn't run hone setup yet → graceful pass
48
+ if (typeof fileExists !== 'function' || !fileExists(pipelineRoot)) {
49
+ return { compliant: true, reason: 'no-pipeline-dir', storyId };
50
+ }
51
+
52
+ // 4. Check the canonical step-0 artifact
53
+ const expected = path.join(pipelineRoot, storyId, 'step-0-grooming.md');
54
+ if (fileExists(expected)) {
55
+ return { compliant: true, reason: 'ok', storyId };
56
+ }
57
+
58
+ // 5. STORY-ID + pipeline dir present, but no grooming → FAIL
59
+ return { compliant: false, reason: 'missing-grooming', storyId, expected };
60
+ }
61
+
62
+ module.exports = { evaluateCompliance };
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+ /**
3
+ * config-augment.js — HC-013a surgical YAML insert/replace for platform
4
+ * discovery sections in .pipeline-config.yml.
5
+ *
6
+ * Pure helper — no I/O. Caller reads + writes the file.
7
+ *
8
+ * Same pattern as config-update.js (H-021): surgical regex preserves
9
+ * adopter comments + structure. Never uses yaml.dump round-trip.
10
+ *
11
+ * Two modes:
12
+ * INSERT — config has platform.detected but no HC-013 marker → add sections
13
+ * REPLACE — config has HC-013 marker → replace managed sections, preserve rest
14
+ *
15
+ * Architecture: docs/architecture/platform-auto-discovery-v1.md (HC-013)
16
+ */
17
+
18
+ /**
19
+ * Format metadata_types as YAML text.
20
+ */
21
+ function formatMetadataTypes(mt) {
22
+ if (!mt) return ' code: []\n config: []\n test: []';
23
+ const lines = [];
24
+ for (const cat of ['code', 'config', 'test']) {
25
+ const items = mt[cat] || [];
26
+ if (items.length === 0) {
27
+ lines.push(` ${cat}: []`);
28
+ } else {
29
+ lines.push(` ${cat}:`);
30
+ for (const item of items) {
31
+ lines.push(` - { type: ${item.type}, path: "${item.path}", count: ${item.count} }`);
32
+ }
33
+ }
34
+ }
35
+ return lines.join('\n');
36
+ }
37
+
38
+ /**
39
+ * Format the managed platform block (everything between marker and next top-level key).
40
+ */
41
+ function formatManagedBlock(data) {
42
+ const lines = [];
43
+ lines.push(` # HC-013: Auto-discovered platform grounding`);
44
+ lines.push(` discovered_at: "${data.discovered_at}"`);
45
+
46
+ // source_roots
47
+ if (data.source_roots && data.source_roots.length > 0) {
48
+ lines.push(` source_roots:`);
49
+ for (const r of data.source_roots) lines.push(` - "${r}"`);
50
+ } else {
51
+ lines.push(` source_roots: []`);
52
+ }
53
+
54
+ // metadata_types
55
+ lines.push(` metadata_types:`);
56
+ lines.push(formatMetadataTypes(data.metadata_types));
57
+
58
+ // config_paths
59
+ if (data.config_paths && data.config_paths.length > 0) {
60
+ lines.push(` config_paths:`);
61
+ for (const p of data.config_paths) lines.push(` - "${p}"`);
62
+ } else {
63
+ lines.push(` config_paths: []`);
64
+ }
65
+
66
+ // doc_registry
67
+ if (data.doc_registry && data.doc_registry.length > 0) {
68
+ lines.push(` doc_registry:`);
69
+ for (const d of data.doc_registry) lines.push(` - ${d}`);
70
+ } else {
71
+ lines.push(` doc_registry: []`);
72
+ }
73
+
74
+ // mcp
75
+ const mcp = data.mcp || {};
76
+ lines.push(` mcp:`);
77
+ lines.push(` enabled: ${mcp.enabled || false}`);
78
+ if (mcp.server) lines.push(` server: "${mcp.server}"`);
79
+ if (mcp.capabilities && mcp.capabilities.length > 0) {
80
+ lines.push(` capabilities: [${mcp.capabilities.join(', ')}]`);
81
+ }
82
+
83
+ return lines.join('\n');
84
+ }
85
+
86
+ /**
87
+ * Surgically insert or replace platform discovery sections.
88
+ *
89
+ * @param {string|null} configText — raw .pipeline-config.yml content
90
+ * @param {object} data — platform discovery data
91
+ * @param {string} data.discovered_at — ISO timestamp
92
+ * @param {string[]} data.source_roots — from project descriptor
93
+ * @param {object} data.metadata_types — { code: [...], config: [...], test: [...] }
94
+ * @param {string[]} data.config_paths — regex patterns for classifier
95
+ * @param {string[]} data.doc_registry — platform keys for doc registry
96
+ * @param {object} data.mcp — { enabled, server, capabilities }
97
+ * @returns {string|null} — augmented config text, or original if no platform section
98
+ */
99
+ function augmentPlatformConfig(configText, data) {
100
+ if (typeof configText !== 'string') return configText;
101
+ if (!data) return configText;
102
+
103
+ // Check if platform: section exists
104
+ if (!/^platform:/m.test(configText)) return configText;
105
+
106
+ const managedBlock = formatManagedBlock(data);
107
+
108
+ // REPLACE mode: HC-013 marker exists → replace from marker to next top-level key
109
+ if (configText.includes('# HC-013:')) {
110
+ // Find the marker and replace everything from it to the next top-level key
111
+ // (a line starting with a non-space character that isn't part of the platform block)
112
+ const replaced = configText.replace(
113
+ / # HC-013:[^\n]*\n(?: [^\n]*\n)*/,
114
+ managedBlock + '\n'
115
+ );
116
+ return replaced;
117
+ }
118
+
119
+ // INSERT mode: no marker → insert after platform.detected block
120
+ // Remove commented config_paths template if present
121
+ let text = configText.replace(/ # config_paths:\n(?: #[^\n]*\n)*/g, '');
122
+
123
+ // Find the end of the detected: block (after all " - platform" lines)
124
+ // Insert the managed block after it
125
+ const insertPoint = text.replace(
126
+ /(^platform:\n detected:\n(?: - [^\n]+\n)+)/m,
127
+ `$1${managedBlock}\n`
128
+ );
129
+
130
+ return insertPoint;
131
+ }
132
+
133
+ module.exports = { augmentPlatformConfig, formatMetadataTypes, formatManagedBlock };
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+ /**
3
+ * config-update.js — H-021 surgical edits to .github/.pipeline-config.yml.
4
+ * Pure helpers — no I/O. Caller (hone-cli.js) reads + writes the file.
5
+ *
6
+ * Why surgical regex (not yaml.dump round-trip): adopters' .pipeline-config.yml
7
+ * has rich comments documenting intent. js-yaml.dump strips them all.
8
+ * For single-field updates, regex preserves comments + structure.
9
+ *
10
+ * 5th instance of the pure-helpers + thin-CLI-shell pattern in cli/lib/
11
+ * after auto-detect.js (H-018), learnings-parse.js (H-035),
12
+ * pipeline-status.js (H-009), metrics-collect.js (H-008).
13
+ *
14
+ * Closes #31.
15
+ */
16
+
17
+ // ─────────────────────────────────────────────────────────────────────
18
+ // stampLastDerived — replace `skill_refresh.last_derived: <old>` with new ISO.
19
+ // ─────────────────────────────────────────────────────────────────────
20
+ // Returns { text, updated }:
21
+ // - updated=true if the `last_derived:` line was found + replaced
22
+ // - updated=false if the line was absent (graceful no-op for caller)
23
+ //
24
+ // Preserves all comments + other fields by doing a single-line regex replace.
25
+ function stampLastDerived(configText, isoTimestamp) {
26
+ if (typeof configText !== 'string' || !isoTimestamp) {
27
+ return { text: configText, updated: false };
28
+ }
29
+ const re = /^(\s*last_derived:\s*).*$/m;
30
+ if (!re.test(configText)) {
31
+ return { text: configText, updated: false };
32
+ }
33
+ const newText = configText.replace(re, `$1"${isoTimestamp}"`);
34
+ return { text: newText, updated: true };
35
+ }
36
+
37
+ // ─────────────────────────────────────────────────────────────────────
38
+ // stampLastDerivedSrcFiles — H-015 — REPLACE existing or INSERT after `last_derived:`
39
+ // ─────────────────────────────────────────────────────────────────────
40
+ // Returns { text, updated }:
41
+ // - updated=true if the line was replaced (existing) or inserted
42
+ // (forward-compat for adopters whose .pipeline-config.yml predates
43
+ // H-015's schema addition)
44
+ // - updated=false if neither `last_derived_src_files:` nor `last_derived:`
45
+ // are present (graceful — caller handles)
46
+ function stampLastDerivedSrcFiles(configText, count) {
47
+ if (typeof configText !== 'string' || count == null) {
48
+ return { text: configText, updated: false };
49
+ }
50
+ // First: replace existing line in place
51
+ const replaceRe = /^(\s*last_derived_src_files:\s*).*$/m;
52
+ if (replaceRe.test(configText)) {
53
+ return {
54
+ text: configText.replace(replaceRe, `$1${count}`),
55
+ updated: true,
56
+ };
57
+ }
58
+ // Otherwise: insert immediately after `last_derived:` line, preserving
59
+ // its indentation (2 spaces under `skill_refresh:` per the schema docs)
60
+ const insertRe = /^(\s*last_derived:[^\n]*)$/m;
61
+ if (insertRe.test(configText)) {
62
+ return {
63
+ text: configText.replace(insertRe, `$1\n last_derived_src_files: ${count}`),
64
+ updated: true,
65
+ };
66
+ }
67
+ return { text: configText, updated: false };
68
+ }
69
+
70
+ module.exports = { stampLastDerived, stampLastDerivedSrcFiles };
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+ /**
3
+ * dependency-audit.js — HC-023 npm audit + pip-audit integration.
4
+ *
5
+ * Pure helper with injected I/O (exec + fileExists).
6
+ * Runs package manager audit commands and parses structured output.
7
+ * Graceful degradation: if tool is missing, returns skipped (not failed).
8
+ *
9
+ * Architecture: docs/architecture/master-roadmap.md (Epic 2, HC-023)
10
+ */
11
+
12
+ /**
13
+ * Run dependency audit for the detected stack.
14
+ *
15
+ * @param {object} opts
16
+ * @param {string} opts.repoRoot
17
+ * @param {string} opts.stack — 'node' | 'python' | etc.
18
+ * @param {(cmd: string) => {stdout: string, exitCode: number}} [opts.exec]
19
+ * @param {(relativePath: string) => boolean} [opts.fileExists]
20
+ * @returns {{ passed: boolean, findings: Array, skipped?: boolean, reason?: string }}
21
+ */
22
+ function runDependencyAudit(opts = {}) {
23
+ const { stack, exec, fileExists } = opts;
24
+
25
+ if (typeof exec !== 'function' || typeof fileExists !== 'function') {
26
+ return { passed: true, findings: [], skipped: true, reason: 'exec or fileExists not provided' };
27
+ }
28
+
29
+ switch (stack) {
30
+ case 'node': return auditNode(exec, fileExists);
31
+ case 'python': return auditPython(exec, fileExists);
32
+ default: return { passed: true, findings: [], skipped: true, reason: `no audit tool for stack: ${stack}` };
33
+ }
34
+ }
35
+
36
+ function auditNode(exec, fileExists) {
37
+ if (!fileExists('package-lock.json') && !fileExists('yarn.lock')) {
38
+ return { passed: true, findings: [], skipped: true, reason: 'no lock file found (package-lock.json or yarn.lock)' };
39
+ }
40
+
41
+ try {
42
+ const result = exec('npm audit --json 2>/dev/null');
43
+ const parsed = JSON.parse(result.stdout);
44
+ const vulns = parsed.vulnerabilities || {};
45
+ const findings = [];
46
+
47
+ for (const [pkg, info] of Object.entries(vulns)) {
48
+ findings.push({
49
+ package: pkg,
50
+ severity: info.severity || 'unknown',
51
+ fixAvailable: !!info.fixAvailable,
52
+ via: Array.isArray(info.via) ? info.via.filter(v => typeof v === 'string') : [],
53
+ });
54
+ }
55
+
56
+ const hasHighOrCritical = findings.some(f =>
57
+ f.severity === 'high' || f.severity === 'critical'
58
+ );
59
+
60
+ return {
61
+ passed: !hasHighOrCritical,
62
+ findings,
63
+ summary: findings.length === 0
64
+ ? 'No known vulnerabilities.'
65
+ : `${findings.length} vulnerabilities found (${findings.filter(f => f.severity === 'critical').length} critical, ${findings.filter(f => f.severity === 'high').length} high).`,
66
+ };
67
+ } catch (e) {
68
+ return { passed: true, findings: [], skipped: true, reason: `npm audit failed: ${e.message}` };
69
+ }
70
+ }
71
+
72
+ function auditPython(exec, fileExists) {
73
+ if (!fileExists('requirements.txt') && !fileExists('pyproject.toml')) {
74
+ return { passed: true, findings: [], skipped: true, reason: 'no requirements.txt or pyproject.toml found' };
75
+ }
76
+
77
+ try {
78
+ const result = exec('pip-audit --format=json 2>/dev/null');
79
+ const parsed = JSON.parse(result.stdout);
80
+ const findings = [];
81
+
82
+ if (Array.isArray(parsed)) {
83
+ for (const item of parsed) {
84
+ for (const vuln of (item.vulns || [])) {
85
+ findings.push({
86
+ package: item.name,
87
+ version: item.version,
88
+ severity: 'high', // pip-audit doesn't always provide severity
89
+ advisory: vuln.id,
90
+ fixVersions: vuln.fix_versions || [],
91
+ });
92
+ }
93
+ }
94
+ }
95
+
96
+ return {
97
+ passed: findings.length === 0,
98
+ findings,
99
+ summary: findings.length === 0
100
+ ? 'No known vulnerabilities.'
101
+ : `${findings.length} vulnerabilities found.`,
102
+ };
103
+ } catch (e) {
104
+ return { passed: true, findings: [], skipped: true, reason: `pip-audit failed: ${e.message}` };
105
+ }
106
+ }
107
+
108
+ module.exports = { runDependencyAudit };