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