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