@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,170 @@
1
+ 'use strict';
2
+ /**
3
+ * stack-detect.js — H-002a pure helpers for source-file counting and the
4
+ * Node-vs-Python rebalance tie-breaker invoked by `setup-ai-pipeline.sh`
5
+ * after greedy stack detection.
6
+ *
7
+ * The bash detection block in `server/scripts/setup-ai-pipeline.sh` is
8
+ * order-sensitive: when a repo has BOTH `package.json` AND a Python
9
+ * manifest (e.g. an OptionsFlow-style backend with a tiny `package.json`
10
+ * for husky / lint-staged tooling), `DETECTED_STACK` lands on `node`
11
+ * because the Node block runs first. That writes the wrong
12
+ * `stack.primary` and `stack.test_framework` to `.pipeline-config.yml`.
13
+ *
14
+ * This module provides the file-count tie-breaker. The bash function
15
+ * `rebalance_node_python` shells out to `node -e` to call
16
+ * `rebalanceNodePython({ root })` — so the counting logic has a single
17
+ * source of truth, is unit-testable, and is reusable by H-028
18
+ * (application_type detection) which wants the same primitives.
19
+ *
20
+ * Exports:
21
+ * - LANG_EXTENSIONS : map of language → array of extensions
22
+ * - countSourceFiles : pure(ish) counter, walks dirs at bounded depth
23
+ * - rebalanceNodePython : returns 'python' or 'node' based on counts
24
+ *
25
+ * Closes #11 (H-002 / H-002a). Stacked under by #54 (H-002b — install
26
+ * path + CLAUDE.md substitution). Reused by H-028 (#51).
27
+ */
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+
32
+ // ─────────────────────────────────────────────────────────────────────
33
+ // Public constants
34
+ // ─────────────────────────────────────────────────────────────────────
35
+
36
+ const LANG_EXTENSIONS = Object.freeze({
37
+ python: ['.py'],
38
+ javascript: ['.js', '.jsx'],
39
+ typescript: ['.ts', '.tsx'],
40
+ });
41
+
42
+ // Directory names whose contents must NEVER be counted, even if a caller
43
+ // passes them in `dirs`. These are build artifacts / vendored deps that
44
+ // would skew the file-count rebalance toward whichever language they ship.
45
+ const SKIP_DIR_NAMES = new Set([
46
+ 'node_modules',
47
+ '.git',
48
+ 'dist',
49
+ 'build',
50
+ '.next',
51
+ '.nuxt',
52
+ 'out',
53
+ 'coverage',
54
+ '__pycache__',
55
+ '.venv',
56
+ 'venv',
57
+ '.tox',
58
+ '.pytest_cache',
59
+ 'target', // Java/Rust build dir
60
+ '.gradle',
61
+ ]);
62
+
63
+ // When the caller does NOT pass `dirs`, we walk the repo root once at
64
+ // `maxDepth`. The skip-list (SKIP_DIR_NAMES + dotdirs) keeps us out of
65
+ // node_modules, .git, dist, build, __pycache__, .venv, etc., which is the
66
+ // only restriction that matters for the file-count rebalance.
67
+ //
68
+ // Walking root once (instead of `['src','app','lib','.']`) avoids the
69
+ // double-counting bug — `src/main.py` would otherwise be counted twice:
70
+ // once via the explicit `'src'` walker, and again via the `'.'` walker
71
+ // recursing into `src/`.
72
+ //
73
+ // DEFAULT_DIRS is exported so callers (and H-028) can build on the same
74
+ // canonical list when they want to explicitly restrict — but the default
75
+ // branch in countSourceFiles() does NOT use it.
76
+ const DEFAULT_DIRS = Object.freeze(['src', 'app', 'lib', '.']);
77
+ const DEFAULT_MAX_DEPTH = 5;
78
+
79
+ // ─────────────────────────────────────────────────────────────────────
80
+ // countSourceFiles({ root, dirs?, maxDepth? })
81
+ // Returns { python, javascript, typescript } counts.
82
+ // - default (no `dirs`): walk `root` once at maxDepth, skip-list aware
83
+ // - explicit `dirs`: walk only the named sub-dirs (each at maxDepth)
84
+ // ─────────────────────────────────────────────────────────────────────
85
+
86
+ function countSourceFiles({ root, dirs, maxDepth = DEFAULT_MAX_DEPTH } = {}) {
87
+ if (!root || typeof root !== 'string') {
88
+ throw new TypeError('countSourceFiles: { root } must be a non-empty string');
89
+ }
90
+
91
+ const counts = { python: 0, javascript: 0, typescript: 0 };
92
+
93
+ // Build a flat reverse map: extension → language key so each filename
94
+ // costs exactly one O(1) lookup in the inner walker.
95
+ const extToLang = Object.create(null);
96
+ for (const [lang, exts] of Object.entries(LANG_EXTENSIONS)) {
97
+ for (const ext of exts) extToLang[ext] = lang;
98
+ }
99
+
100
+ if (Array.isArray(dirs)) {
101
+ // Explicit caller-restricted walk. Each named dir is walked
102
+ // independently — caller is responsible for not passing an
103
+ // overlapping list (e.g. ['.', 'src']).
104
+ for (const dir of dirs) {
105
+ const absDir = dir === '.' ? root : path.join(root, dir);
106
+ walk(absDir, 0, maxDepth, extToLang, counts);
107
+ }
108
+ } else {
109
+ // Default: single walk from root, skip-list filters out the dirs
110
+ // that would otherwise pollute counts.
111
+ walk(root, 0, maxDepth, extToLang, counts);
112
+ }
113
+
114
+ return counts;
115
+ }
116
+
117
+ // Walker — bounded-depth, skip-list aware, swallows EACCES/ENOENT so a
118
+ // missing optional dir like `app/` on a `src/`-only repo isn't fatal.
119
+ function walk(absDir, depth, maxDepth, extToLang, counts) {
120
+ if (depth > maxDepth) return;
121
+
122
+ let entries;
123
+ try {
124
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
125
+ } catch (err) {
126
+ if (err.code === 'ENOENT' || err.code === 'EACCES' || err.code === 'ENOTDIR') return;
127
+ throw err;
128
+ }
129
+
130
+ for (const ent of entries) {
131
+ const name = ent.name;
132
+ if (ent.isDirectory()) {
133
+ if (SKIP_DIR_NAMES.has(name)) continue;
134
+ if (name.startsWith('.') && name !== '.' && name !== '..') {
135
+ // Skip dotdirs by default (.github, .vscode, .idea, etc.) —
136
+ // these aren't application source.
137
+ continue;
138
+ }
139
+ walk(path.join(absDir, name), depth + 1, maxDepth, extToLang, counts);
140
+ } else if (ent.isFile()) {
141
+ const ext = path.extname(name);
142
+ const lang = extToLang[ext];
143
+ if (lang) counts[lang] += 1;
144
+ }
145
+ // Symlinks intentionally not followed — avoids cycles and fs traversal
146
+ // into vendored dirs that link back into node_modules.
147
+ }
148
+ }
149
+
150
+ // ─────────────────────────────────────────────────────────────────────
151
+ // rebalanceNodePython({ root })
152
+ // Returns 'python' if .py files outnumber .js/.jsx/.ts/.tsx files.
153
+ // Returns 'node' on tie or empty repo (preserves current default).
154
+ // ─────────────────────────────────────────────────────────────────────
155
+
156
+ function rebalanceNodePython({ root } = {}) {
157
+ const counts = countSourceFiles({ root });
158
+ const nodeCount = counts.javascript + counts.typescript;
159
+ const pythonCount = counts.python;
160
+ return pythonCount > nodeCount ? 'python' : 'node';
161
+ }
162
+
163
+ module.exports = {
164
+ LANG_EXTENSIONS,
165
+ SKIP_DIR_NAMES,
166
+ DEFAULT_DIRS,
167
+ DEFAULT_MAX_DEPTH,
168
+ countSourceFiles,
169
+ rebalanceNodePython,
170
+ };
@@ -0,0 +1,285 @@
1
+ 'use strict';
2
+ /**
3
+ * stack-paths.js — Framework-agnostic path classifier (H-028).
4
+ *
5
+ * Replaces the Next.js-centric path heuristic in
6
+ * `scripts/seed-agent-prompts.js` (HONE-011) with a table covering 9
7
+ * frameworks: Next.js, Vue, Angular, Django, Flask, Rails, FastAPI,
8
+ * Express, and Salesforce LWC/Apex.
9
+ *
10
+ * The classifier returns one of:
11
+ * - BROWSER : files match only browser/UI paths
12
+ * - API : files match only server/API paths
13
+ * - HYBRID : both browser AND api signals present (mixed-stack —
14
+ * preserves the HONE-011 contract for fullstack repos)
15
+ * - UNKNOWN : no recognized signal (or invalid/empty input)
16
+ *
17
+ * Pure helper. No side effects. Consumed by:
18
+ * - agent prompts (E2E QA Planner) for scenario tagging
19
+ * - future story: setup-ai-pipeline.sh stack.application_type derivation
20
+ *
21
+ * Issue: https://github.com/subbareddyvani/hone-server/issues/51
22
+ */
23
+
24
+ // Patterns are anchored by file extension where overlapping paths between
25
+ // frameworks would otherwise produce false positives. For example:
26
+ // - `app/views.py` is Django (a `.py` file at module root) — must NOT
27
+ // match Next.js's `app/` router or Rails's `app/views/`.
28
+ // - `app/views/users.py` is Flask (`.py` under `app/views/`) — must NOT
29
+ // match Rails's `app/views/` (Rails uses `.erb`/`.haml`).
30
+ const STACK_PATH_TABLE = {
31
+ nextjs: {
32
+ // Next.js sources are .ts/.tsx/.js/.jsx/.css/.scss only — anchor by ext
33
+ browser: [
34
+ /^src\/pages\//,
35
+ /^src\/components\//,
36
+ /^app\/.*\.(tsx?|jsx?|css|scss)$/,
37
+ /^pages\/.*\.(tsx?|jsx?)$/,
38
+ ],
39
+ api: [/^server\//, /^src\/api\//, /^app\/api\/.*\.(tsx?|jsx?)$/, /^pages\/api\//],
40
+ },
41
+ vue: {
42
+ // .vue is the canonical disambiguator
43
+ browser: [/\.vue$/],
44
+ api: [],
45
+ },
46
+ angular: {
47
+ browser: [/\.component\.ts$/, /^src\/app\/components\//, /^src\/app\/pages\//],
48
+ api: [],
49
+ },
50
+ django: {
51
+ browser: [/templates\/.*\.html$/],
52
+ api: [/(^|\/)views\.py$/, /(^|\/)urls\.py$/, /(^|\/)api\.py$/],
53
+ },
54
+ flask: {
55
+ browser: [/app\/templates\/.*\.html$/],
56
+ api: [/^app\/(views|api|routes)\/.*\.py$/],
57
+ },
58
+ rails: {
59
+ // Rails views are .erb/.haml; controllers and routes are .rb
60
+ browser: [/^app\/views\/.*\.(erb|haml)$/, /^app\/javascript\//],
61
+ api: [/^app\/controllers\/.*\.rb$/, /^config\/routes\.rb$/],
62
+ },
63
+ fastapi: {
64
+ browser: [],
65
+ api: [/^app\/routers\/.*\.py$/, /^app\/api\/.*\.py$/, /(^|\/)main\.py$/],
66
+ },
67
+ express: {
68
+ browser: [],
69
+ api: [/^routes\/.*\.(j|t)s$/, /^controllers\/.*\.(j|t)s$/, /^api\/.*\.(j|t)s$/],
70
+ },
71
+ salesforce: {
72
+ browser: [/^force-app\/main\/default\/(lwc|aura)\//],
73
+ api: [/^force-app\/main\/default\/(classes|triggers)\//],
74
+ // Declarative metadata (SFDX) — XML files deployed via `sf project deploy`.
75
+ // CONFIG-only stories test via sandbox deploy + UI smoke + metadata API
76
+ // round-trip; do NOT generate Playwright/API specs.
77
+ config: [
78
+ /^force-app\/main\/default\/objects\//,
79
+ /^force-app\/main\/default\/(profiles|permissionsets|permissionsetgroups)\//,
80
+ /^force-app\/main\/default\/(flows|workflows)\//,
81
+ /^force-app\/main\/default\/(layouts|flexipages|compactLayouts)\//,
82
+ /^force-app\/main\/default\/(customMetadata|customSettings|globalValueSets)\//,
83
+ /^force-app\/main\/default\/(applications|tabs)\//,
84
+ /^force-app\/main\/default\/(email|reports|dashboards)\//,
85
+ /^force-app\/main\/default\/(sharingRules|approvalProcesses)\//,
86
+ /^force-app\/main\/default\/staticresources\//,
87
+ ],
88
+ },
89
+ // NetSuite SuiteScript (SDF projects). Scripts live under
90
+ // src/FileCabinet/SuiteScripts/<feature>/<ScriptType>/ but adopters use
91
+ // varied roots — patterns match the script-type directory anywhere in the
92
+ // path, and tolerate both "ClientScripts" and "Client Scripts" naming.
93
+ // Suitelets are classified as API even though they may render UI: the
94
+ // testable surface is HTTP-handler logic (same precedent as Salesforce Apex).
95
+ netsuite: {
96
+ browser: [
97
+ /\/(ClientScripts|Client Scripts|Portlets)\//,
98
+ /\.ssp$/,
99
+ ],
100
+ api: [
101
+ /\/(RESTlets|Suitelets|UserEventScripts|User Event Scripts|MapReduce|Map Reduce|ScheduledScripts|Scheduled Scripts|WorkflowActions|Workflow Actions|WorkflowActionScripts|MassUpdate|Mass Update)\//,
102
+ ],
103
+ // Declarative metadata (SDF) — XML files deployed via `suitecloud
104
+ // project:deploy`. Same CONFIG strategy as Salesforce.
105
+ config: [
106
+ /^src\/Objects\//,
107
+ /^src\/AccountConfiguration\//,
108
+ /^src\/Translations\//,
109
+ ],
110
+ },
111
+ };
112
+
113
+ // Paths that should never count toward classification (test fixtures, deps,
114
+ // docs, VCS metadata). The classifier filters these out before scanning.
115
+ const IGNORED_PATH_PATTERNS = [
116
+ /^tests?\//,
117
+ /^node_modules\//,
118
+ /^\.git\//,
119
+ /^docs\//,
120
+ /^dist\//,
121
+ /^build\//,
122
+ ];
123
+
124
+ /**
125
+ * Coerce an array of patterns (mix of RegExp and strings) into RegExp[].
126
+ * Invalid string patterns are silently dropped — they neither crash nor
127
+ * spuriously match. This is the H-028d defensive behavior for adopter-
128
+ * supplied `extraConfigPaths`.
129
+ *
130
+ * @param {Array<RegExp|string>} patterns
131
+ * @returns {RegExp[]}
132
+ */
133
+ function coerceToRegexArray(patterns) {
134
+ if (!Array.isArray(patterns)) return [];
135
+ const out = [];
136
+ for (const p of patterns) {
137
+ if (p instanceof RegExp) {
138
+ out.push(p);
139
+ } else if (typeof p === 'string' && p.length > 0) {
140
+ try { out.push(new RegExp(p)); } catch { /* invalid regex — skip */ }
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * Classify a list of file paths into one of: BROWSER / API / HYBRID / CONFIG / UNKNOWN.
148
+ *
149
+ * Categories:
150
+ * - BROWSER : client-side / UI code (test via Playwright UI specs)
151
+ * - API : server-side handlers (test via Playwright `request` /
152
+ * pytest-httpx / node:test + fetch)
153
+ * - HYBRID : both code signals OR (any code signal + CONFIG metadata) —
154
+ * needs mixed test strategy
155
+ * - CONFIG : declarative metadata only (Salesforce SFDX, NetSuite SDF,
156
+ * or adopter-supplied via opts.extraConfigPaths). Test via
157
+ * sandbox deploy + UI smoke + metadata API round-trip.
158
+ * Do NOT generate Playwright/API specs.
159
+ * - UNKNOWN : no recognized signal (or invalid/empty input)
160
+ *
161
+ * H-028d adds the optional `opts` argument with `extraConfigPaths` —
162
+ * lets adopters union custom regex/string patterns with the built-in
163
+ * config table (e.g., `^infra/`, `^helm/`, `^k8s/`). Default empty →
164
+ * backward-compatible with H-028c callers.
165
+ *
166
+ * @param {string[]} paths - file paths from a step-1-plan.md or git diff.
167
+ * @param {object} [opts]
168
+ * @param {Array<RegExp|string>} [opts.extraConfigPaths] - additional
169
+ * patterns to count as CONFIG. Strings are coerced to RegExp; invalid
170
+ * patterns are silently skipped.
171
+ * @returns {'BROWSER'|'API'|'HYBRID'|'CONFIG'|'UNKNOWN'}
172
+ */
173
+ function classifyPathsForStack(paths, opts = {}) {
174
+ if (!Array.isArray(paths) || paths.length === 0) return 'UNKNOWN';
175
+
176
+ const extraConfigPatterns = coerceToRegexArray(opts && opts.extraConfigPaths);
177
+
178
+ let hasBrowser = false;
179
+ let hasApi = false;
180
+ let hasConfig = false;
181
+
182
+ for (const p of paths) {
183
+ if (typeof p !== 'string') continue;
184
+ if (IGNORED_PATH_PATTERNS.some((rx) => rx.test(p))) continue;
185
+
186
+ for (const fw of Object.values(STACK_PATH_TABLE)) {
187
+ if (!hasBrowser && fw.browser.some((rx) => rx.test(p))) hasBrowser = true;
188
+ if (!hasApi && fw.api.some((rx) => rx.test(p))) hasApi = true;
189
+ if (!hasConfig && (fw.config || []).some((rx) => rx.test(p))) hasConfig = true;
190
+ }
191
+ // Adopter-supplied extras unioned with built-ins
192
+ if (!hasConfig && extraConfigPatterns.some((rx) => rx.test(p))) hasConfig = true;
193
+
194
+ if (hasBrowser && hasApi && hasConfig) break; // all signals settled
195
+ }
196
+
197
+ // CONFIG mixed with any code signal → HYBRID (sandbox deploy + code test).
198
+ // Pure CONFIG → CONFIG (sandbox + UI smoke only).
199
+ if ((hasBrowser && hasApi) || (hasConfig && (hasBrowser || hasApi))) return 'HYBRID';
200
+ if (hasBrowser) return 'BROWSER';
201
+ if (hasApi) return 'API';
202
+ if (hasConfig) return 'CONFIG';
203
+ return 'UNKNOWN';
204
+ }
205
+
206
+ /**
207
+ * Parse adopter-supplied `platform.config_paths:` patterns from a
208
+ * `.pipeline-config.yml` text. Minimal regex-based scan — keeps the
209
+ * helper dep-free (no js-yaml). Adopters with esoteric YAML can fall
210
+ * back to calling `classifyPathsForStack(paths, { extraConfigPaths })`
211
+ * directly with their own loader.
212
+ *
213
+ * @param {string} yamlText
214
+ * @returns {string[]} pattern strings (NOT yet coerced to regex)
215
+ */
216
+ function parsePlatformConfigPaths(yamlText) {
217
+ if (typeof yamlText !== 'string' || yamlText.length === 0) return [];
218
+ // Find the `platform:` block (top-level key, ignoring leading whitespace
219
+ // sensitivity by anchoring to start of line).
220
+ const blockMatch = yamlText.match(/^platform:\s*\n((?:[ \t]+.*\n?)*)/m);
221
+ if (!blockMatch) return [];
222
+ const block = blockMatch[1];
223
+ // Look for `config_paths:` then collect `- "..."` items at the same indent
224
+ const cpStart = block.search(/^\s*config_paths:\s*$/m);
225
+ if (cpStart < 0) return [];
226
+ const after = block.slice(cpStart);
227
+ const out = [];
228
+ // Match list items: ` - "pattern"` or ` - 'pattern'` or ` - pattern`
229
+ const re = /^\s*-\s*(?:"([^"]*)"|'([^']*)'|([^\s].*?))\s*$/gm;
230
+ let m;
231
+ while ((m = re.exec(after)) !== null) {
232
+ const v = m[1] || m[2] || m[3];
233
+ if (v) out.push(v.trim());
234
+ }
235
+ return out;
236
+ }
237
+
238
+ /**
239
+ * Higher-level classifier composing platform fingerprint detection +
240
+ * adopter-supplied `.pipeline-config.yml platform.config_paths` + the
241
+ * pure `classifyPathsForStack` core.
242
+ *
243
+ * Pure-helper-with-injected-I/O shape (same as compliance-check.js):
244
+ * caller wraps `fs.existsSync` and `fs.readFileSync` so the helper can
245
+ * be unit-tested with stubs.
246
+ *
247
+ * @param {object} [opts]
248
+ * @param {string[]} opts.paths - file paths to classify
249
+ * @param {string} opts.repoRoot - absolute path to repo root
250
+ * @param {(relativePath: string) => boolean} [opts.fileExists] - filesystem check
251
+ * @param {(relativePath: string) => string|null} [opts.readFile] - file reader
252
+ * @returns {{ category: string, detectedPlatforms: string[], configPathsUsed: string[] }}
253
+ */
254
+ function classifyPathsForRepo(opts = {}) {
255
+ const { paths, repoRoot, fileExists, readFile } = opts;
256
+
257
+ // Platform fingerprint detection (H-028d helper)
258
+ let detectedPlatforms = [];
259
+ try {
260
+ const { detectPlatforms } = require('./platform-detect');
261
+ detectedPlatforms = detectPlatforms({ repoRoot, fileExists });
262
+ } catch { /* defensive — if the module is unavailable, treat as no platforms */ }
263
+
264
+ // Read adopter-supplied platform.config_paths from .pipeline-config.yml (best-effort)
265
+ let configPathsUsed = [];
266
+ try {
267
+ if (typeof readFile === 'function') {
268
+ const yamlText = readFile('.pipeline-config.yml');
269
+ if (typeof yamlText === 'string' && yamlText.length > 0) {
270
+ configPathsUsed = parsePlatformConfigPaths(yamlText);
271
+ }
272
+ }
273
+ } catch { /* defensive — bad YAML / missing file is a no-op */ }
274
+
275
+ const category = classifyPathsForStack(paths, { extraConfigPaths: configPathsUsed });
276
+ return { category, detectedPlatforms, configPathsUsed };
277
+ }
278
+
279
+ module.exports = {
280
+ STACK_PATH_TABLE,
281
+ IGNORED_PATH_PATTERNS,
282
+ classifyPathsForStack,
283
+ classifyPathsForRepo,
284
+ parsePlatformConfigPaths,
285
+ };
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+ /**
3
+ * story-classifier-extract.js — SC-002 (Phase 2 of SC family).
4
+ * Heuristic extractors that infer 4 of the 9 classifyStory() inputs from
5
+ * a GitHub issue's labels + body + title + the repo's git history.
6
+ *
7
+ * The other 5 inputs (adopter_blast_radius, surface_area, estimate,
8
+ * design_options, has_test_matrix, is_first_of_its_kind) are JUDGMENT
9
+ * fields that the CLI prompts for OR accepts via --input-file. They
10
+ * cannot reliably be heuristically extracted from text alone.
11
+ *
12
+ * Pure-helper-with-injected-IO style:
13
+ * - Extractors take parsed inputs (labels array, body string, etc.)
14
+ * - The git-log query is the one extractor that needs the repo path,
15
+ * and shells out to git via execSync — same pattern as
16
+ * cli/lib/git-helpers.js.
17
+ *
18
+ * Issue: user-request 2026-05-05 SC-002. Builds on SC-001's classifyStory().
19
+ */
20
+
21
+ const { execSync } = require('node:child_process');
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ // ── Label-to-type canonical mapping ──
26
+ // First-match-wins per label-array order.
27
+ const LABEL_TO_TYPE = Object.freeze({
28
+ bug: 'bug',
29
+ enhancement: 'enhancement',
30
+ feature: 'feature',
31
+ refactor: 'refactor',
32
+ refactoring: 'refactor',
33
+ documentation: 'docs',
34
+ docs: 'docs',
35
+ chore: 'chore',
36
+ epic: 'meta-epic',
37
+ meta: 'meta-epic',
38
+ 'meta-epic': 'meta-epic',
39
+ });
40
+
41
+ // Title-prefix patterns (case-insensitive) → type
42
+ const TITLE_PREFIXES = [
43
+ { re: /^\s*\[(meta|epic|meta-epic)\]/i, type: 'meta-epic' },
44
+ { re: /^\s*(fix|bug):/i, type: 'bug' },
45
+ { re: /^\s*(feat|feature):/i, type: 'feature' },
46
+ { re: /^\s*(docs|doc):/i, type: 'docs' },
47
+ { re: /^\s*(chore):/i, type: 'chore' },
48
+ { re: /^\s*(refactor):/i, type: 'refactor' },
49
+ ];
50
+
51
+ // Body keywords → type (tested word-boundary)
52
+ const BODY_TYPE_KEYWORDS = [
53
+ { re: /\bregression\b/i, type: 'bug' },
54
+ { re: /\bproduction failure\b/i, type: 'bug' },
55
+ { re: /\bbroken in\b/i, type: 'bug' },
56
+ { re: /\bbroke (the|a|something)\b/i, type: 'bug' },
57
+ ];
58
+
59
+ // Security keywords (case-insensitive, word-boundary)
60
+ const SECURITY_KEYWORDS = [
61
+ /\bauth\b/i,
62
+ /\btoken\b/i,
63
+ /\bcredential[s]?\b/i,
64
+ /\bsecret[s]?\b/i,
65
+ /\bCVE-\d{4}-\d+\b/i,
66
+ /\bXSS\b/i,
67
+ /\bCSRF\b/i,
68
+ /\binjection\b/i,
69
+ /\bsanitize\b/i,
70
+ /\bencrypt\b/i,
71
+ /\bdecrypt\b/i,
72
+ /\bpassword[s]?\b/i,
73
+ /\bOAuth\b/i,
74
+ /\bJWT\b/i,
75
+ ];
76
+
77
+ // Cross-repo phrases (signal that something OUTSIDE this repo is a dependency)
78
+ const CROSS_REPO_PHRASES = [
79
+ /\bblocked\s+by\b/i,
80
+ /\bblocked-by\b/i,
81
+ /\bdepends\s+on\b/i,
82
+ /\bdepends-on\b/i,
83
+ /\bupstream\b/i,
84
+ /\brequires\s+#/i,
85
+ /\bafter\s+#/i,
86
+ ];
87
+
88
+ // Cross-repo URL/ref patterns
89
+ const CROSS_REPO_REF = /\b([\w.-]+\/[\w.-]+)#\d+\b/; // owner/repo#NN
90
+ const CROSS_REPO_URL = /https?:\/\/github\.com\/[^\s\/]+\/[^\s\/]+\/(issues|pull)\/\d+/;
91
+
92
+ // ── Public API ──
93
+
94
+ /**
95
+ * Infer story type from labels + title + body.
96
+ *
97
+ * @param {string[]|null} labels
98
+ * @param {string|null} body
99
+ * @param {string} [title]
100
+ * @returns {'bug'|'enhancement'|'feature'|'refactor'|'docs'|'chore'|'meta-epic'}
101
+ */
102
+ function extractType(labels, body, title) {
103
+ // 1. Labels (most authoritative)
104
+ const safeLabels = Array.isArray(labels) ? labels : [];
105
+ for (const label of safeLabels) {
106
+ const key = String(label || '').toLowerCase();
107
+ if (LABEL_TO_TYPE[key]) return LABEL_TO_TYPE[key];
108
+ }
109
+ // 2. Title prefix
110
+ const safeTitle = typeof title === 'string' ? title : '';
111
+ for (const { re, type } of TITLE_PREFIXES) {
112
+ if (re.test(safeTitle)) return type;
113
+ }
114
+ // 3. Body keywords
115
+ const safeBody = typeof body === 'string' ? body : '';
116
+ for (const { re, type } of BODY_TYPE_KEYWORDS) {
117
+ if (re.test(safeBody)) return type;
118
+ }
119
+ // 4. Default
120
+ return 'enhancement';
121
+ }
122
+
123
+ /**
124
+ * Detect whether a story has security implications.
125
+ *
126
+ * @param {string[]|null} labels
127
+ * @param {string|null} body
128
+ * @returns {boolean}
129
+ */
130
+ function extractSecurityImplications(labels, body) {
131
+ const safeLabels = Array.isArray(labels) ? labels : [];
132
+ // 1. Label
133
+ for (const label of safeLabels) {
134
+ if (String(label || '').toLowerCase() === 'security') return true;
135
+ }
136
+ // 2. Keywords in body
137
+ const safeBody = typeof body === 'string' ? body : '';
138
+ for (const re of SECURITY_KEYWORDS) {
139
+ if (re.test(safeBody)) return true;
140
+ }
141
+ return false;
142
+ }
143
+
144
+ /**
145
+ * Detect whether a story depends on work in a different repo.
146
+ *
147
+ * @param {string|null} body
148
+ * @returns {boolean}
149
+ */
150
+ function extractCrossRepoDependency(body) {
151
+ const safeBody = typeof body === 'string' ? body : '';
152
+ if (!safeBody) return false;
153
+ // 1. Cross-repo ref form (owner/repo#NN)
154
+ if (CROSS_REPO_REF.test(safeBody)) return true;
155
+ // 2. Cross-repo URL
156
+ if (CROSS_REPO_URL.test(safeBody)) return true;
157
+ // 3. Phrase-based (depends on, blocked by, upstream, etc.)
158
+ for (const re of CROSS_REPO_PHRASES) {
159
+ if (re.test(safeBody)) return true;
160
+ }
161
+ return false;
162
+ }
163
+
164
+ /**
165
+ * Detect whether any of the touched files has a recent commit within the
166
+ * configured window. Indicates same-file overlap with recent prior work.
167
+ *
168
+ * @param {string} repoRoot
169
+ * @param {string[]} touchedFiles
170
+ * @param {number} [windowDays=14]
171
+ * @returns {boolean}
172
+ */
173
+ function extractRecentlyModifiedOverlap(repoRoot, touchedFiles, windowDays) {
174
+ const w = (typeof windowDays === 'number' && windowDays > 0) ? windowDays : 14;
175
+ if (!repoRoot || typeof repoRoot !== 'string') return false;
176
+ if (!Array.isArray(touchedFiles) || touchedFiles.length === 0) return false;
177
+ for (const rel of touchedFiles) {
178
+ if (typeof rel !== 'string' || !rel) continue;
179
+ const abs = path.join(repoRoot, rel);
180
+ if (!fs.existsSync(abs)) continue;
181
+ try {
182
+ const out = execSync(
183
+ `git log --since="${w} days ago" --format=%H -- ${JSON.stringify(rel)}`,
184
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
185
+ ).trim();
186
+ if (out) return true;
187
+ } catch {
188
+ // Git unavailable / not a repo — silently continue
189
+ }
190
+ }
191
+ return false;
192
+ }
193
+
194
+ module.exports = {
195
+ extractType,
196
+ extractSecurityImplications,
197
+ extractCrossRepoDependency,
198
+ extractRecentlyModifiedOverlap,
199
+ // Exported for test-shape assertions + future extension
200
+ LABEL_TO_TYPE,
201
+ SECURITY_KEYWORDS,
202
+ CROSS_REPO_PHRASES,
203
+ };