@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
package/lib/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # `cli/lib/` — Pure Helpers Convention
2
+
3
+ > Last updated: 2026-05-04 (#119 helper-shape taxonomy)
4
+
5
+ All helpers in `cli/lib/` are PURE (no I/O, no `process.exit`, no console
6
+ output) and accept dependency-injection callbacks (`fileExists`, `readFile`,
7
+ `listDir`) so they're unit-testable with stubs. The CLI shell in
8
+ `cli/hone-cli.js` provides the actual filesystem callbacks.
9
+
10
+ ## Helper-shape taxonomy (#119)
11
+
12
+ A code review (2026-05-04) flagged "inconsistent error shape across the 4
13
+ editor-integrity helpers" as a minor concern. The honest read after audit:
14
+ **different helper categories LEGITIMATELY use different shapes.** Forcing
15
+ all into one shape would harm clarity. This document codifies the taxonomy.
16
+
17
+ ### Category A: Detection helpers
18
+ **Return:** `string[]` (array of detected platforms / editors / etc.)
19
+ **Examples:**
20
+ - `cli/lib/platform-detect.js` → `string[]` of platform IDs
21
+ - `cli/lib/editor-detect.js` → `string[]` of editor IDs
22
+
23
+ **Why:** "Detect what's here" maps cleanly to a list. No findings/diagnostics
24
+ needed at this layer; failures are just an empty array.
25
+
26
+ ### Category B: Validation helpers
27
+ **Return:** `{ findings: Array<{severity, category, ...}>, summary: object }`
28
+ **Examples:**
29
+ - `cli/lib/skill-audit.js` → `{ findings, summary }`
30
+ (AU-001 / #159 — also exports `classifyPathCandidate(p, ctx)` →
31
+ `{ verdict, reason }` as a pure-helper sub-function. Verdicts:
32
+ `is_file_path`, `is_url`, `is_python_dotted`, `is_code_block`,
33
+ `is_inline_prose`, `unclassified`. Accepts `ignoreList: string[]`
34
+ for adopter-supplied allow-list at `.hone/audit-skills.ignore.yml`.)
35
+ - `cli/lib/pipeline-validate.js` → `{ findings, summary }`
36
+ - `cli/lib/adversarial-negative-lint.js` → `{ findings, summary }`
37
+ - `cli/lib/skill-assertions.js` → `{ findings, scannedSkills, skipped, warnings }`
38
+ - `cli/lib/story-classifier.js` → `{ track, architect, agents, artifacts, rationale, inputs_echo }`
39
+ (SC-001 / 2026-05-05 — `classifyStory(input, thresholds?)` recommends SDLC
40
+ track + architect engagement axes + per-step agent list per story. Pure
41
+ helper; pipeline-of-rules architecture per AU-001-L1. Sub-exports:
42
+ `TRACKS`, `ARCHITECT_AXES`, `DEFAULT_THRESHOLDS`. Validated against 41-story
43
+ corpus at .github/pipeline/SC-001/classifier-corpus.yml — 100% match
44
+ overall.)
45
+ - `cli/lib/story-classifier-extract.js` — heuristic extractors (SC-002).
46
+ Pure helpers: `extractType(labels, body, title)`,
47
+ `extractSecurityImplications(labels, body)`,
48
+ `extractCrossRepoDependency(body)`,
49
+ `extractRecentlyModifiedOverlap(repoRoot, touchedFiles, windowDays)`.
50
+ Used by `hone classify-story` to infer 4 of the 9 classifyStory inputs
51
+ from a GitHub issue. Other 5 fields are user-supplied (judgment).
52
+ - `cli/lib/publish-learning.js` — SC-005 publish framework-canonical
53
+ learning to `server/seeds/learnings/<ID>.yml` + flip source's
54
+ `status: pending → promoted` + stamp `promoted_at`. Eligibility:
55
+ `share_enterprise: true` AND `enterprise_candidate: true` (top-level
56
+ or per-learning). Idempotent (`already-promoted` on repeat); `--force`
57
+ re-publishes. Returns `{ status, copiedTo?, error? }`.
58
+ - `cli/lib/pipeline-config.js` — adopter-overridable thresholds (SC-003).
59
+ `readStoryClassifierConfig(repoRoot)` returns
60
+ `{ estimate_full_sdlc?, recently_modified_window_days? }` from
61
+ `.pipeline-config.yml`'s `story_classifier:` block. Returns empty `{}`
62
+ on any failure. Safety-critical rules (R1/R2/R3) are NOT readable
63
+ from config — framework-fixed.
64
+ (SA-001 / #137 — `runAssertions()`. Carries scope diagnostics — `scannedSkills`,
65
+ `skipped` — alongside findings, since Step 5b reports per-skill coverage.
66
+ Severity values: `BLOCKER`, `WARN`, `INFO` — Step 5b's BLOCKER ≈ Category B's
67
+ `ERROR` semantically; named differently because gating happens in the CLI
68
+ shell, not the helper.)
69
+
70
+ **Why:** Validation produces a heterogeneous list of issues at varying
71
+ severities. The findings array + summary counts is the canonical shape.
72
+ **Severity values:** `ERROR`, `WARN`, `INFO` (Category B canonical) — or
73
+ domain-equivalent names where the deployment context calls for them.
74
+ **Summary fields:** `error_count`, `warn_count`, `info_count`, plus
75
+ domain-specific (`files_scanned`, `editors_validated`, etc.).
76
+
77
+ ### Category C: Transformation helpers
78
+ **Return:** `{ <output>: <transformed_value>, applied: [], errors: [], <diagnostics>: ... }`
79
+ **Examples:**
80
+ - `cli/lib/overlay-merge.js` → `{ merged, applied, errors, conflicts }`
81
+ - `cli/lib/config-update.js` → `{ updated_text, fields_changed }`
82
+
83
+ **Why:** Transformation produces NEW content. The shape carries the
84
+ content + per-input diagnostics + accumulated errors. Forcing this into
85
+ the validation shape would lose the content output.
86
+
87
+ ### Category D: Verification helpers
88
+ **Return:** `{ passed: boolean, results: Array<{found, ...}> }`
89
+ **Examples:**
90
+ - `cli/lib/synthetic-pipeline.js` `verifyMarkers()` → `{ passed, results }`
91
+
92
+ **Why:** Verification answers a yes/no question with per-check evidence.
93
+ The pass/fail is the headline result; the results array is for diagnostics.
94
+
95
+ ### Category E: Composition / resolution helpers
96
+ **Return:** `{ <resolved_inputs>, <derived_view>, _error?: string }`
97
+ **Examples:**
98
+ - `cli/lib/rule-resolver.js` `resolveRules()` → `{ canonical, overlays, merged }`
99
+
100
+ **Why:** Composition pulls from multiple sources and returns a unified view.
101
+ The shape names each input layer + the derived output. Defensive `_error`
102
+ field for callback failures.
103
+
104
+ ## Defensive conventions across all categories
105
+
106
+ - **Missing required callback** → return early with `_error` field or empty
107
+ result. Never throw.
108
+ - **Malformed input** → log via the appropriate diagnostic field
109
+ (errors / findings / _error). Never throw.
110
+ - **Path traversal** → reject paths starting with `/`, `..`, or outside the
111
+ expected directory.
112
+ - **Frozen tables** → deep-copy before returning if caller might mutate
113
+ (see RR-003-L1 learning).
114
+
115
+ ## When to add a new helper category
116
+
117
+ If a new helper truly doesn't fit any of A-E, propose a new category here
118
+ with rationale. Don't silently invent a 6th shape — that's the inconsistency
119
+ this doc exists to prevent.
@@ -0,0 +1,149 @@
1
+ 'use strict';
2
+ /**
3
+ * adversarial-negative-lint.js — HS-005 lint enforcing the META-Q2-L2 rule:
4
+ * "for every positive assertion, write at least one adversarial-negative test."
5
+ *
6
+ * Pure helper that takes test file content + path, parses test() declarations,
7
+ * categorizes by name (positive / negative), and flags files that lack any
8
+ * negative-asserting test if an allow-list comment is absent.
9
+ *
10
+ * Designed for node:test conventions (assert.equal/ok). Not a full AST parser
11
+ * — uses regex over `test('...')` declarations because that's what node:test
12
+ * uses and it's vastly lighter than @babel/parser.
13
+ *
14
+ * Allow-list mechanism: a comment matching
15
+ * // hs005-allowlist: <reason>
16
+ * at the top of the file (within first 30 lines) exempts the file with the
17
+ * given justification.
18
+ *
19
+ * Initial release: warn-only mode. Returns findings; caller decides
20
+ * exit code. After 1-week soak + audit of existing tests, promote to
21
+ * required CI check.
22
+ *
23
+ * Closes architect plan #121 HS-005.
24
+ */
25
+
26
+ // Negative-assertion keywords in test names. If a test() declaration's name
27
+ // includes any of these, the test is considered to assert a NEGATIVE case.
28
+ const NEGATIVE_KEYWORDS = [
29
+ 'rejects', // "REJECTS gameable input"
30
+ 'fails', // "fails when X"
31
+ 'does not', // "does not match"
32
+ 'doesn\'t', // "doesn't accept"
33
+ 'must not', // "must not pass"
34
+ 'should not', // "should not match"
35
+ 'no match', // "no match for X"
36
+ 'no findings', // "no findings when Y"
37
+ 'invalid', // "invalid input → error"
38
+ 'malformed', // "malformed YAML → []"
39
+ 'missing', // "missing fileExists callback"
40
+ 'defensive', // "defensive on missing X"
41
+ 'bad input', // "bad input → []"
42
+ 'gameable', // "gameable input"
43
+ 'adversarial', // "adversarial-negative"
44
+ 'empty', // "empty array → ..."
45
+ ];
46
+
47
+ const ALLOWLIST_PATTERN = /\/\/\s*hs005-allowlist:\s*(.+?)$/im;
48
+
49
+ /**
50
+ * Lint one test file.
51
+ *
52
+ * @param {string} filePath - relative path (for reporting)
53
+ * @param {string} content - file content
54
+ * @returns {{
55
+ * filePath: string,
56
+ * totalTests: number,
57
+ * negativeTests: number,
58
+ * positiveTests: number,
59
+ * hasAllowlist: boolean,
60
+ * allowlistReason?: string,
61
+ * needsAdversarial: boolean, // true if positive-only AND no allow-list
62
+ * }}
63
+ */
64
+ function lintTestFile(filePath, content) {
65
+ if (typeof content !== 'string') {
66
+ return { filePath, totalTests: 0, negativeTests: 0, positiveTests: 0, hasAllowlist: false, needsAdversarial: false };
67
+ }
68
+
69
+ // Allow-list check (must be in top-of-file comment block, first ~30 lines)
70
+ const top = content.split('\n').slice(0, 30).join('\n');
71
+ const allowlistMatch = top.match(ALLOWLIST_PATTERN);
72
+ const hasAllowlist = !!allowlistMatch;
73
+ const allowlistReason = hasAllowlist ? allowlistMatch[1].trim() : undefined;
74
+
75
+ // Find all test() declarations: `test('name', ...)` or `test("name", ...)`
76
+ // Skip skipped/todo (test('name', { skip: ... } or test.skip)
77
+ const testRe = /(?:^|\n)\s*test(?:\.skip|\.only)?\s*\(\s*['"`]([^'"`]+)['"`]/gm;
78
+ let m;
79
+ let totalTests = 0;
80
+ let negativeTests = 0;
81
+ while ((m = testRe.exec(content)) !== null) {
82
+ totalTests++;
83
+ const name = m[1].toLowerCase();
84
+ if (NEGATIVE_KEYWORDS.some((kw) => name.includes(kw))) {
85
+ negativeTests++;
86
+ }
87
+ }
88
+ const positiveTests = totalTests - negativeTests;
89
+ const needsAdversarial = totalTests > 0 && negativeTests === 0 && !hasAllowlist;
90
+
91
+ return {
92
+ filePath,
93
+ totalTests,
94
+ negativeTests,
95
+ positiveTests,
96
+ hasAllowlist,
97
+ allowlistReason,
98
+ needsAdversarial,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Lint a set of test files.
104
+ *
105
+ * @param {Array<{filePath, content}>} files
106
+ * @returns {{ findings: Array, summary: object }}
107
+ */
108
+ function lintTestFiles(files) {
109
+ if (!Array.isArray(files)) return { findings: [], summary: { files_scanned: 0 } };
110
+
111
+ const findings = [];
112
+ let totalFlagged = 0;
113
+ let totalScanned = 0;
114
+ let totalAllowlisted = 0;
115
+
116
+ for (const file of files) {
117
+ if (!file || typeof file.filePath !== 'string') continue;
118
+ totalScanned++;
119
+ const result = lintTestFile(file.filePath, file.content);
120
+ if (result.hasAllowlist) totalAllowlisted++;
121
+ if (result.needsAdversarial) {
122
+ totalFlagged++;
123
+ findings.push({
124
+ severity: 'WARN', // warn-only initial release
125
+ category: 'MISSING_ADVERSARIAL_NEGATIVE',
126
+ file: result.filePath,
127
+ message: `${result.totalTests} test(s) all assert positive cases. Per HS-005 / META-Q2-L2: add ≥1 adversarial-negative test (REJECTS / does not / must fail / etc.) OR add allow-list comment "// hs005-allowlist: <reason>" within first 30 lines.`,
128
+ positiveTests: result.positiveTests,
129
+ negativeTests: result.negativeTests,
130
+ });
131
+ }
132
+ }
133
+
134
+ const summary = {
135
+ files_scanned: totalScanned,
136
+ files_flagged: totalFlagged,
137
+ files_allowlisted: totalAllowlisted,
138
+ files_compliant: totalScanned - totalFlagged - totalAllowlisted,
139
+ };
140
+
141
+ return { findings, summary };
142
+ }
143
+
144
+ module.exports = {
145
+ NEGATIVE_KEYWORDS,
146
+ ALLOWLIST_PATTERN,
147
+ lintTestFile,
148
+ lintTestFiles,
149
+ };
package/lib/audit.js ADDED
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+ /**
3
+ * audit.js — H-020 pure helpers for `hone audit`.
4
+ *
5
+ * Walks the in-memory representation of `.github/pipeline/<STORY>/` and
6
+ * scorecards each story against required + optional step artifacts.
7
+ *
8
+ * No I/O happens here — the CLI shell (hone-cli.js) is responsible for
9
+ * reading the directory tree and passing { storyId, files[] } objects in.
10
+ * That keeps everything synchronous, deterministic, and unit-testable
11
+ * without touching the filesystem.
12
+ */
13
+
14
+ const DEFAULT_REQUIRED_STEPS = [
15
+ 'step-0-grooming.md',
16
+ 'step-1-plan.md',
17
+ 'step-2-tests.md',
18
+ 'step-4-implementation.md',
19
+ 'step-5-review.md',
20
+ ];
21
+
22
+ const DEFAULT_OPTIONAL_STEPS = [
23
+ 'step-3-e2e-plan.md',
24
+ ];
25
+
26
+ // Some adopters store artifacts as STEP2_TESTS.md / step_2_tests.md.
27
+ // Normalize before comparing so we don't false-positive missing.
28
+ function normalize(name) {
29
+ return String(name).toLowerCase().replace(/_/g, '-');
30
+ }
31
+
32
+ /**
33
+ * Score a single story.
34
+ *
35
+ * @param {object} story
36
+ * @param {string} story.storyId e.g. "E20-A"
37
+ * @param {string[]} story.files basename list of files in that story dir
38
+ * @param {object} [opts]
39
+ * @param {string[]} [opts.requiredSteps]
40
+ * @param {string[]} [opts.optionalSteps]
41
+ * @returns {{ storyId, status, present, missing, optionalPresent, score }}
42
+ */
43
+ function scoreStory(story, opts = {}) {
44
+ const required = opts.requiredSteps || DEFAULT_REQUIRED_STEPS;
45
+ const optional = opts.optionalSteps || DEFAULT_OPTIONAL_STEPS;
46
+ const have = new Set((story.files || []).map(normalize));
47
+
48
+ const present = required.filter((r) => have.has(normalize(r)));
49
+ const missing = required.filter((r) => !have.has(normalize(r)));
50
+ const optionalPresent = optional.filter((o) => have.has(normalize(o)));
51
+
52
+ let status;
53
+ if (present.length === 0 && missing.length === required.length && have.size === 0) {
54
+ status = 'EMPTY';
55
+ } else if (missing.length === 0) {
56
+ status = 'COMPLETE';
57
+ } else if (missing.length <= 1) {
58
+ status = 'NEAR_COMPLETE';
59
+ } else {
60
+ status = 'INCOMPLETE';
61
+ }
62
+
63
+ const score = `${present.length}/${required.length}`;
64
+
65
+ return {
66
+ storyId: story.storyId,
67
+ status,
68
+ present,
69
+ missing,
70
+ optionalPresent,
71
+ score,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Score every story in the pipeline.
77
+ *
78
+ * @param {Array<{storyId:string, files:string[]}>} stories
79
+ * @param {object} [opts]
80
+ * @returns {{ stories: object[], summary: { total, complete, nearComplete, incomplete, empty, percentComplete } }}
81
+ */
82
+ function auditPipeline(stories, opts = {}) {
83
+ const filterStory = opts.story || null;
84
+ const filtered = filterStory
85
+ ? stories.filter((s) => s.storyId === filterStory)
86
+ : stories;
87
+
88
+ const scored = filtered.map((s) => scoreStory(s, opts));
89
+ const total = scored.length;
90
+ const complete = scored.filter((s) => s.status === 'COMPLETE').length;
91
+ const nearComplete = scored.filter((s) => s.status === 'NEAR_COMPLETE').length;
92
+ const incomplete = scored.filter((s) => s.status === 'INCOMPLETE').length;
93
+ const empty = scored.filter((s) => s.status === 'EMPTY').length;
94
+ const percentComplete = total === 0 ? 0 : Math.round((complete / total) * 100);
95
+
96
+ return {
97
+ stories: scored,
98
+ summary: { total, complete, nearComplete, incomplete, empty, percentComplete },
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Render a human-readable table for terminal output.
104
+ */
105
+ function renderTable(audit) {
106
+ const { stories, summary } = audit;
107
+ if (stories.length === 0) {
108
+ return 'No pipeline stories found under .github/pipeline/';
109
+ }
110
+
111
+ const lines = [];
112
+ lines.push('Scanning .github/pipeline/ ...');
113
+ lines.push('');
114
+ lines.push('Story | Steps | Missing | Status');
115
+ lines.push('-------------|-------|--------------------------------------|----------------');
116
+ for (const s of stories) {
117
+ const id = s.storyId.padEnd(12);
118
+ const score = s.score.padEnd(5);
119
+ const missing = (s.missing.join(', ') || '').padEnd(36);
120
+ const tag = statusTag(s.status);
121
+ lines.push(`${id} | ${score} | ${missing} | ${tag}`);
122
+ }
123
+ lines.push('');
124
+ lines.push(
125
+ `Pipeline compliance: ${summary.complete}/${summary.total} (${summary.percentComplete}%)`
126
+ );
127
+ if (summary.nearComplete > 0) lines.push(`Near-complete: ${summary.nearComplete}`);
128
+ if (summary.incomplete > 0) lines.push(`Incomplete: ${summary.incomplete}`);
129
+ if (summary.empty > 0) lines.push(`Empty: ${summary.empty}`);
130
+ return lines.join('\n');
131
+ }
132
+
133
+ function statusTag(status) {
134
+ switch (status) {
135
+ case 'COMPLETE':
136
+ return 'OK COMPLETE';
137
+ case 'NEAR_COMPLETE':
138
+ return '~ NEAR-COMPLETE';
139
+ case 'INCOMPLETE':
140
+ return 'X INCOMPLETE';
141
+ case 'EMPTY':
142
+ return 'X EMPTY';
143
+ default:
144
+ return status;
145
+ }
146
+ }
147
+
148
+ module.exports = {
149
+ scoreStory,
150
+ auditPipeline,
151
+ renderTable,
152
+ statusTag,
153
+ normalize,
154
+ DEFAULT_REQUIRED_STEPS,
155
+ DEFAULT_OPTIONAL_STEPS,
156
+ };
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+ /**
3
+ * auto-detect.js — H-018 pure helpers for stack/convention detection.
4
+ *
5
+ * The CLI shell collects filesystem signals (file lists, branch names,
6
+ * pipeline dir names, package.json) and passes them in. We return
7
+ * recommended `story_id_pattern` and `e2e_spec_pattern` plus a
8
+ * confidence tag so the caller can decide whether to (a) write straight
9
+ * to .pipeline-config.yml, (b) print a "we detected X — confirm?"
10
+ * summary, or (c) fail loud if ambiguous.
11
+ *
12
+ * Why pure: detection is pattern-matching over inputs. Keeping all I/O
13
+ * in the CLI shell makes the rules unit-testable without temp dirs.
14
+ */
15
+
16
+ // ── Story-ID detection ─────────────────────────────────────────────
17
+
18
+ const HONE_SHAPE = /^E\d+-[A-Z]+/; // E20-A, E22-H, E1-AB
19
+ const JIRA_SHAPE = /^[A-Z]+-\d+/; // ABC-123, OPS-42
20
+ const WIDEST_PATTERN = 'E[0-9]+-[A-Z]+|[A-Z]+-[0-9]+';
21
+
22
+ /**
23
+ * Look at observed story IDs (from branch names + .github/pipeline/ dir
24
+ * names) and decide which shape this repo uses.
25
+ *
26
+ * @param {string[]} samples — strings like ["feature/E20-A-add-foo", "ABC-123-fix"]
27
+ * @returns {{ pattern: string, shape: 'hone'|'jira'|'mixed'|'unknown', confidence: 'high'|'medium'|'low', samples: string[] }}
28
+ */
29
+ function detectStoryIdShape(samples) {
30
+ const hits = { hone: 0, jira: 0 };
31
+ const matched = [];
32
+ for (const s of samples || []) {
33
+ // Strip common branch prefixes before matching.
34
+ const stripped = String(s).replace(/^(feature|fix|chore|feat|hotfix)\//, '');
35
+ if (HONE_SHAPE.test(stripped)) {
36
+ hits.hone++;
37
+ matched.push(stripped.match(HONE_SHAPE)[0]);
38
+ } else if (JIRA_SHAPE.test(stripped)) {
39
+ hits.jira++;
40
+ matched.push(stripped.match(JIRA_SHAPE)[0]);
41
+ }
42
+ }
43
+
44
+ const total = hits.hone + hits.jira;
45
+ if (total === 0) {
46
+ return { pattern: WIDEST_PATTERN, shape: 'unknown', confidence: 'low', samples: [] };
47
+ }
48
+
49
+ if (hits.hone > 0 && hits.jira === 0) {
50
+ return {
51
+ pattern: 'E[0-9]+-[A-Z]+',
52
+ shape: 'hone',
53
+ confidence: hits.hone >= 3 ? 'high' : 'medium',
54
+ samples: matched.slice(0, 5),
55
+ };
56
+ }
57
+ if (hits.jira > 0 && hits.hone === 0) {
58
+ return {
59
+ pattern: '[A-Z]+-[0-9]+',
60
+ shape: 'jira',
61
+ confidence: hits.jira >= 3 ? 'high' : 'medium',
62
+ samples: matched.slice(0, 5),
63
+ };
64
+ }
65
+ // Mixed → use the widest pattern that catches both.
66
+ return {
67
+ pattern: WIDEST_PATTERN,
68
+ shape: 'mixed',
69
+ confidence: 'medium',
70
+ samples: matched.slice(0, 5),
71
+ };
72
+ }
73
+
74
+ // ── E2E convention detection ───────────────────────────────────────
75
+
76
+ /**
77
+ * Decide which E2E spec pattern fits this repo.
78
+ *
79
+ * Inputs are coarse-grained signals the CLI collects:
80
+ * hasPyproject — pyproject.toml exists
81
+ * hasRequirements — requirements.txt exists
82
+ * hasPlaywrightDep — package.json mentions @playwright/test or playwright
83
+ * testsDir — true if tests/ exists (plural)
84
+ * testDir — true if test/ exists (singular)
85
+ * sampleE2EFiles — list of basenames found under tests?/e2e/ (or [])
86
+ *
87
+ * @returns {{ pattern: string, framework: 'pytest'|'playwright-ts'|'playwright-js'|'unknown'|'mixed', confidence, dir: 'tests'|'test'|'unknown' }}
88
+ */
89
+ function detectE2EConvention(signals) {
90
+ const s = signals || {};
91
+ const isPython = !!(s.hasPyproject || s.hasRequirements);
92
+ const isPlaywright = !!s.hasPlaywrightDep;
93
+ const dir = s.testsDir ? 'tests' : s.testDir ? 'test' : 'unknown';
94
+
95
+ // Look at observed E2E filenames first — most reliable signal.
96
+ const observedPy = (s.sampleE2EFiles || []).some((f) => /^test_.*\.py$/i.test(f));
97
+ const observedTs = (s.sampleE2EFiles || []).some((f) => /\.spec\.ts$/i.test(f));
98
+ const observedJs = (s.sampleE2EFiles || []).some((f) =>
99
+ /\.spec\.js$/i.test(f) && !/\.spec\.ts$/i.test(f)
100
+ );
101
+
102
+ // Mixed = both python and TS/JS specs in the same E2E dir.
103
+ if (observedPy && (observedTs || observedJs)) {
104
+ return {
105
+ pattern: `${dir === 'unknown' ? 'tests?' : dir}/e2e/(test_.*\\.py|.*\\.spec\\.(ts|js))$`,
106
+ framework: 'mixed',
107
+ confidence: 'high',
108
+ dir,
109
+ };
110
+ }
111
+ if (observedPy) {
112
+ return {
113
+ pattern: `${dir === 'unknown' ? 'tests?' : dir}/e2e/test_.*\\.py$`,
114
+ framework: 'pytest',
115
+ confidence: 'high',
116
+ dir,
117
+ };
118
+ }
119
+ if (observedTs) {
120
+ return {
121
+ pattern: `${dir === 'unknown' ? 'tests?' : dir}/e2e/.*\\.spec\\.ts$`,
122
+ framework: 'playwright-ts',
123
+ confidence: 'high',
124
+ dir,
125
+ };
126
+ }
127
+ if (observedJs) {
128
+ return {
129
+ pattern: `${dir === 'unknown' ? 'tests?' : dir}/e2e/.*\\.spec\\.js$`,
130
+ framework: 'playwright-js',
131
+ confidence: 'high',
132
+ dir,
133
+ };
134
+ }
135
+
136
+ // No observed files — fall back to dependency / manifest signals.
137
+ if (isPython && !isPlaywright) {
138
+ return {
139
+ pattern: `${dir === 'unknown' ? 'tests?' : dir}/e2e/test_.*\\.py$`,
140
+ framework: 'pytest',
141
+ confidence: 'medium',
142
+ dir,
143
+ };
144
+ }
145
+ if (isPlaywright && !isPython) {
146
+ return {
147
+ pattern: `${dir === 'unknown' ? 'tests?' : dir}/e2e/.*\\.spec\\.(ts|js)$`,
148
+ framework: 'playwright-ts',
149
+ confidence: 'medium',
150
+ dir,
151
+ };
152
+ }
153
+ if (isPython && isPlaywright) {
154
+ return {
155
+ pattern: `tests?/e2e/(test_.*\\.py|.*\\.spec\\.(ts|js))$`,
156
+ framework: 'mixed',
157
+ confidence: 'medium',
158
+ dir,
159
+ };
160
+ }
161
+ // Nothing detected — widest possible pattern.
162
+ return {
163
+ pattern: `tests?/e2e/(test_.*\\.py|.*\\.spec\\.(ts|js))$`,
164
+ framework: 'unknown',
165
+ confidence: 'low',
166
+ dir,
167
+ };
168
+ }
169
+
170
+ // ── Drift checker (used by `hone doctor --check patterns`) ─────────
171
+
172
+ /**
173
+ * Compare the patterns currently in .pipeline-config.yml against what
174
+ * we recommend. Returns either status:'ok' or status:'drift' with an
175
+ * actionable suggested fix.
176
+ */
177
+ function checkPatternDrift({ configured, recommended }) {
178
+ const findings = [];
179
+ if (configured?.story_id_pattern && configured.story_id_pattern !== recommended.story.pattern) {
180
+ findings.push({
181
+ key: 'story_id_pattern',
182
+ configured: configured.story_id_pattern,
183
+ recommended: recommended.story.pattern,
184
+ reason: `repo looks like ${recommended.story.shape} (${recommended.story.confidence} confidence)`,
185
+ });
186
+ }
187
+ if (configured?.e2e_spec_pattern && configured.e2e_spec_pattern !== recommended.e2e.pattern) {
188
+ findings.push({
189
+ key: 'e2e_spec_pattern',
190
+ configured: configured.e2e_spec_pattern,
191
+ recommended: recommended.e2e.pattern,
192
+ reason: `detected ${recommended.e2e.framework} under ${recommended.e2e.dir}/e2e/ (${recommended.e2e.confidence} confidence)`,
193
+ });
194
+ }
195
+
196
+ if (findings.length === 0) {
197
+ return { status: 'ok', findings: [], reason: 'patterns match detected conventions' };
198
+ }
199
+ return {
200
+ status: 'drift',
201
+ findings,
202
+ reason: `${findings.length} pattern(s) drift from detected conventions`,
203
+ suggestedFix:
204
+ 'Update .github/.pipeline-config.yml ci.* and the inlined regex in .github/workflows/ai-review.yml to the recommended values above.',
205
+ };
206
+ }
207
+
208
+ module.exports = {
209
+ detectStoryIdShape,
210
+ detectE2EConvention,
211
+ checkPatternDrift,
212
+ WIDEST_PATTERN,
213
+ };