@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,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* pipeline-config.js — SC-003 (Phase 3 of SC family).
|
|
4
|
+
* Pure helper to read adopter-overridable thresholds from
|
|
5
|
+
* `.pipeline-config.yml`'s `story_classifier:` block.
|
|
6
|
+
*
|
|
7
|
+
* Reads from (in order):
|
|
8
|
+
* 1. <repoRoot>/.pipeline-config.yml
|
|
9
|
+
* 2. <repoRoot>/.github/.pipeline-config.yml
|
|
10
|
+
*
|
|
11
|
+
* Failure modes (all degrade gracefully — return empty object):
|
|
12
|
+
* - file missing
|
|
13
|
+
* - file unreadable / unparseable YAML
|
|
14
|
+
* - story_classifier block absent
|
|
15
|
+
* - block has unknown keys (passed through; helper just extracts)
|
|
16
|
+
*
|
|
17
|
+
* The 9 inputs to classifyStory are NOT read from config — those come
|
|
18
|
+
* per-story from the issue or --input-file. This config holds only
|
|
19
|
+
* adopter-team-level THRESHOLD DEFAULTS (estimate, recently-modified
|
|
20
|
+
* window) that apply across all stories.
|
|
21
|
+
*
|
|
22
|
+
* Safety-critical rules (R1/R2/R3 in story-classifier.js) are
|
|
23
|
+
* NOT configurable — adopter cannot downgrade bug+high-blast away
|
|
24
|
+
* from hot-fix, security away from full-sdlc, or first-of-its-kind
|
|
25
|
+
* away from full-sdlc, regardless of what they put in this config.
|
|
26
|
+
*
|
|
27
|
+
* Issue: user-request 2026-05-05 SC-003. Builds on SC-001/SC-002.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
const KNOWN_THRESHOLD_KEYS = ['estimate_full_sdlc', 'recently_modified_window_days'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read story-classifier thresholds from .pipeline-config.yml.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} repoRoot
|
|
39
|
+
* @returns {{ estimate_full_sdlc?: string, recently_modified_window_days?: number }}
|
|
40
|
+
* Empty object if config is missing, malformed, or has no story_classifier block.
|
|
41
|
+
*/
|
|
42
|
+
function readStoryClassifierConfig(repoRoot) {
|
|
43
|
+
if (!repoRoot || typeof repoRoot !== 'string') return {};
|
|
44
|
+
|
|
45
|
+
const candidates = [
|
|
46
|
+
path.join(repoRoot, '.pipeline-config.yml'),
|
|
47
|
+
path.join(repoRoot, '.github/.pipeline-config.yml'),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
for (const p of candidates) {
|
|
51
|
+
if (!fs.existsSync(p)) continue;
|
|
52
|
+
let raw;
|
|
53
|
+
try { raw = fs.readFileSync(p, 'utf8'); }
|
|
54
|
+
catch { continue; }
|
|
55
|
+
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
const yaml = require('js-yaml');
|
|
59
|
+
parsed = yaml.load(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
// Malformed YAML — silently skip (not a fatal error)
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
66
|
+
const block = parsed.story_classifier;
|
|
67
|
+
if (!block || typeof block !== 'object') continue;
|
|
68
|
+
|
|
69
|
+
// Pick out only the known threshold keys; ignore unknowns
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const key of KNOWN_THRESHOLD_KEYS) {
|
|
72
|
+
if (block[key] !== undefined) out[key] = block[key];
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
readStoryClassifierConfig,
|
|
82
|
+
KNOWN_THRESHOLD_KEYS,
|
|
83
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* pipeline-status.js — H-009 pure helpers for the `hone status` and
|
|
4
|
+
* `hone next` CLI commands. No I/O — callers (hone-cli.js) do all
|
|
5
|
+
* filesystem reads and pass parsed inputs in. Keeps all rules
|
|
6
|
+
* unit-testable without temp dirs.
|
|
7
|
+
*
|
|
8
|
+
* Same shape as cli/lib/auto-detect.js (H-018) and cli/lib/learnings-parse.js
|
|
9
|
+
* (H-035) — pure helpers, all I/O at the CLI shell.
|
|
10
|
+
*
|
|
11
|
+
* Closes #18.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const yaml = require('js-yaml');
|
|
15
|
+
|
|
16
|
+
// ── Canonical step file naming convention (H-009/A2) ───────────────
|
|
17
|
+
// Verified across .github/pipeline/H-001/, H-014/, H-029/, H-035/, H-076/.
|
|
18
|
+
// step_3b is conditional (manual E2E mode only).
|
|
19
|
+
// step_5b is conditional (SA-002 skill audit, present when story consults skills).
|
|
20
|
+
// step_5c is conditional (H-076 CI gate, present after PR creation).
|
|
21
|
+
//
|
|
22
|
+
// SR-002 (#160): added step_5b + step_5c. Single STEPS table consolidates
|
|
23
|
+
// the 4 prior parallel objects (file/display/agent maps + order array)
|
|
24
|
+
// into one declaration so the "add a new step" affordance is a single edit.
|
|
25
|
+
// Future steps go here without touching the helpers below.
|
|
26
|
+
const STEPS = [
|
|
27
|
+
{ key: 'step_0', file: 'step-0-grooming.md', display: 'Step 0 — Grooming', agent: 'story-groomer', conditional: false },
|
|
28
|
+
{ key: 'step_1', file: 'step-1-plan.md', display: 'Step 1 — Plan', agent: 'implementation-planner', conditional: false },
|
|
29
|
+
{ key: 'step_2', file: 'step-2-tests.md', display: 'Step 2 — Unit Tests', agent: 'unit-test-writer', conditional: false },
|
|
30
|
+
{ key: 'step_3a', file: 'step-3-e2e-plan.md', display: 'Step 3a — E2E Plan', agent: 'e2e-qa-planner', conditional: false },
|
|
31
|
+
{ key: 'step_3b', file: 'step-3b-spec.md', display: 'Step 3b — E2E Spec', agent: 'e2e-test-spec-writer', conditional: true },
|
|
32
|
+
{ key: 'step_4', file: 'step-4-implementation.md', display: 'Step 4 — Implementation', agent: 'code-builder', conditional: false },
|
|
33
|
+
{ key: 'step_5', file: 'step-5-review.md', display: 'Step 5 — Review', agent: 'code-reviewer', conditional: false },
|
|
34
|
+
{ key: 'step_5b', file: 'step-5b-skill-audit.md', display: 'Step 5b — Skill Audit', agent: 'code-reviewer', conditional: true },
|
|
35
|
+
{ key: 'step_5c', file: 'step-5c-ci.md', display: 'Step 5c — CI Gate', agent: 'code-reviewer', conditional: true },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Derived maps preserved for back-compat — same shape, same exports.
|
|
39
|
+
// Existing callers (cli/hone-cli.js + tests + downstream tooling) read
|
|
40
|
+
// STEP_FILE_MAP / STEP_DISPLAY_NAME / STEP_AGENT / STEP_ORDER unchanged.
|
|
41
|
+
const STEP_FILE_MAP = Object.fromEntries(STEPS.map(s => [s.key, s.file]));
|
|
42
|
+
const STEP_DISPLAY_NAME = Object.fromEntries(STEPS.map(s => [s.key, s.display]));
|
|
43
|
+
const STEP_AGENT = Object.fromEntries(STEPS.map(s => [s.key, s.agent]));
|
|
44
|
+
const STEP_ORDER = STEPS.map(s => s.key);
|
|
45
|
+
const CONDITIONAL_STEPS = new Set(STEPS.filter(s => s.conditional).map(s => s.key));
|
|
46
|
+
|
|
47
|
+
// SR-001 (#142): canonical STORY_ID_PATTERN — UNION of two adopter
|
|
48
|
+
// conventions. The single-alternative form documented in H-005 silently
|
|
49
|
+
// failed to match the second alternative's inputs (e.g., it claimed to
|
|
50
|
+
// match "E13-A" but actually returned the wrong substring of any
|
|
51
|
+
// branch like "feature/E34-A-...").
|
|
52
|
+
//
|
|
53
|
+
// Now matches:
|
|
54
|
+
// 1. E[0-9]+-[A-Z]<rest> — OptionsFlow's "E<num>-<letter>" convention
|
|
55
|
+
// (E13-A, E34-A, E29-H, etc.)
|
|
56
|
+
// 2. [A-Z]+[-_]<alphanum> — Jira (ABC-123), Hone (H-009), LC-001,
|
|
57
|
+
// SA-001, RP-001, underscore variants
|
|
58
|
+
// (STORY_123)
|
|
59
|
+
//
|
|
60
|
+
// Alternation order matters: the E-prefix alternative is tried first
|
|
61
|
+
// so digits-in-middle stories (E34-A) match correctly even when the
|
|
62
|
+
// second alternative would also greedily-match a later substring
|
|
63
|
+
// (A-resolve in "feature/E34-A-resolve-...").
|
|
64
|
+
//
|
|
65
|
+
// Substituted into ai-review.yml at install time via
|
|
66
|
+
// server/scripts/setup-ai-pipeline.sh (which still ships the OLD
|
|
67
|
+
// single-alt default for back-compat with existing .pipeline-config.yml
|
|
68
|
+
// files; updating that default is a separate PR).
|
|
69
|
+
//
|
|
70
|
+
// Reused here for branch → STORY-ID extraction in:
|
|
71
|
+
// - `hone status` / `hone next` (existing)
|
|
72
|
+
// - `hone step-5b` (SA-002, this is what surfaced the bug — when run
|
|
73
|
+
// against OptionsFlow's feature/E34-A-* branches, story id was
|
|
74
|
+
// mis-extracted as "A-resolve")
|
|
75
|
+
const STORY_ID_PATTERN = /(E[0-9]+-[A-Z][A-Za-z0-9]*|[A-Z]+[-_][A-Za-z0-9]+)/;
|
|
76
|
+
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
78
|
+
// extractStoryIdFromBranch — pure regex on branch name
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
80
|
+
function extractStoryIdFromBranch(branchName) {
|
|
81
|
+
if (!branchName || typeof branchName !== 'string') return null;
|
|
82
|
+
const m = branchName.match(STORY_ID_PATTERN);
|
|
83
|
+
return m ? m[0] : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
87
|
+
// parseMetadata — YAML parse + shape validation, graceful catch on errors
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
89
|
+
function parseMetadata(yamlText) {
|
|
90
|
+
let parsed;
|
|
91
|
+
try { parsed = yaml.load(yamlText); }
|
|
92
|
+
catch (e) { return { error: 'malformed', message: e.message }; }
|
|
93
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
94
|
+
return { error: 'empty' };
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
storyId: parsed.story_id || null,
|
|
98
|
+
title: parsed.title || null,
|
|
99
|
+
branch: parsed.branch || null,
|
|
100
|
+
base: parsed.base || null,
|
|
101
|
+
steps: parsed.steps || {},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
106
|
+
// getStepIcon — 5-state icon for one step
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
108
|
+
// '⏭️' if status is skipped (HC-007: intentional skip overrides file presence)
|
|
109
|
+
// '✓' if metadata says completed/approved AND artifact exists
|
|
110
|
+
// '⏳' if status is in_progress|pending AND artifact exists
|
|
111
|
+
// '✗' if status is failed AND artifact exists (SR-002: surface step_5c failures)
|
|
112
|
+
// '⬜' otherwise (filesystem wins on disagreement — AC-5)
|
|
113
|
+
//
|
|
114
|
+
// Filesystem-as-source-of-truth: even when metadata says completed, if
|
|
115
|
+
// the underlying .md file is missing, return ⬜. Otherwise we'd lie to
|
|
116
|
+
// the operator about pipeline state.
|
|
117
|
+
//
|
|
118
|
+
// SR-002 (#160): added 'approved' as a "done" status (used by step_5b /
|
|
119
|
+
// step_5c for human-approved gates) and 'failed' as a distinct icon
|
|
120
|
+
// (so a CI gate failure doesn't masquerade as in-progress).
|
|
121
|
+
//
|
|
122
|
+
// HC-007 (#163): added 'skipped' — boundary-below-threshold stories
|
|
123
|
+
// legitimately skip step_3b; rendering ⬜ (gap) is a false alarm.
|
|
124
|
+
// Skipped overrides file presence because the decision is recorded in
|
|
125
|
+
// metadata, not in the filesystem.
|
|
126
|
+
const DONE_STATUSES = new Set(['completed', 'approved']);
|
|
127
|
+
const IN_PROGRESS_STATUSES = new Set(['in_progress', 'pending']);
|
|
128
|
+
function getStepIcon(metadataStatus, artifactExists) {
|
|
129
|
+
if (metadataStatus === 'skipped') return '⏭️';
|
|
130
|
+
if (!artifactExists) return '⬜';
|
|
131
|
+
if (DONE_STATUSES.has(metadataStatus)) return '✓';
|
|
132
|
+
if (IN_PROGRESS_STATUSES.has(metadataStatus)) return '⏳';
|
|
133
|
+
if (metadataStatus === 'failed') return '✗';
|
|
134
|
+
return '⬜';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
138
|
+
// computeStepStates — combine parsed metadata with filesystem presence
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
140
|
+
function computeStepStates(parsedMetadata, artifactPresence) {
|
|
141
|
+
const result = [];
|
|
142
|
+
const steps = parsedMetadata.steps || {};
|
|
143
|
+
for (const key of STEP_ORDER) {
|
|
144
|
+
const meta = steps[key] || {};
|
|
145
|
+
const fileExists = !!artifactPresence[key];
|
|
146
|
+
// SR-002 (#160): conditional steps (step_3b, step_5b, step_5c) skip
|
|
147
|
+
// when neither metadata nor file exists. Same convention as step_3b
|
|
148
|
+
// pre-SR-002 — generalized to all conditional steps now that we have
|
|
149
|
+
// 3 of them.
|
|
150
|
+
if (CONDITIONAL_STEPS.has(key) && !meta.status && !fileExists) continue;
|
|
151
|
+
const entry = {
|
|
152
|
+
key,
|
|
153
|
+
displayName: STEP_DISPLAY_NAME[key],
|
|
154
|
+
agent: STEP_AGENT[key],
|
|
155
|
+
icon: getStepIcon(meta.status, fileExists),
|
|
156
|
+
status: meta.status || 'not_started',
|
|
157
|
+
artifactPath: STEP_FILE_MAP[key],
|
|
158
|
+
};
|
|
159
|
+
// HC-007 (#163): surface skip reason so tracker/CLI can display it
|
|
160
|
+
if (meta.status === 'skipped' && meta.reason) {
|
|
161
|
+
entry.reason = meta.reason;
|
|
162
|
+
}
|
|
163
|
+
result.push(entry);
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
169
|
+
// findNextIncompleteStep — first step where icon !== '✓' and not skipped
|
|
170
|
+
// HC-007: skipped steps are intentionally not done — don't report them as
|
|
171
|
+
// "next incomplete" in `hone next`.
|
|
172
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
173
|
+
function findNextIncompleteStep(stepStates) {
|
|
174
|
+
return stepStates.find(s => s.icon !== '✓' && s.icon !== '⏭️') || null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
178
|
+
// summarizeForAll — one-line summary for `hone status --all`
|
|
179
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
180
|
+
function summarizeForAll(parsedMetadata, artifactPresence) {
|
|
181
|
+
const states = computeStepStates(parsedMetadata, artifactPresence);
|
|
182
|
+
const next = findNextIncompleteStep(states);
|
|
183
|
+
const summary = next
|
|
184
|
+
? `${next.displayName} ${next.icon === '⏳' ? 'in progress' : 'not started'}`
|
|
185
|
+
: 'pipeline complete';
|
|
186
|
+
return {
|
|
187
|
+
storyId: parsedMetadata.storyId,
|
|
188
|
+
branch: parsedMetadata.branch,
|
|
189
|
+
summary,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
// Constants (testable shape)
|
|
195
|
+
STEP_FILE_MAP,
|
|
196
|
+
STEP_DISPLAY_NAME,
|
|
197
|
+
STEP_AGENT,
|
|
198
|
+
STEP_ORDER,
|
|
199
|
+
STORY_ID_PATTERN,
|
|
200
|
+
// Pure helpers
|
|
201
|
+
extractStoryIdFromBranch,
|
|
202
|
+
parseMetadata,
|
|
203
|
+
getStepIcon,
|
|
204
|
+
computeStepStates,
|
|
205
|
+
findNextIncompleteStep,
|
|
206
|
+
summarizeForAll,
|
|
207
|
+
};
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* pipeline-validate.js — H-081 editor-aware pipeline validation.
|
|
4
|
+
*
|
|
5
|
+
* Performs the per-editor + cross-editor checks proposed in #81 component 3:
|
|
6
|
+
* a. Discovery: agent files exist at the editor's expected paths
|
|
7
|
+
* b. Step coverage: every pipeline step has an agent prompt or inline section
|
|
8
|
+
* c. Rule presence: each canonical enforcement rule appears in editor surface
|
|
9
|
+
* d. Format validity: per-editor format expectations met
|
|
10
|
+
* e. Drift detection: every canonical rule appears in EVERY editor projection
|
|
11
|
+
* f. Skill referenced: every skill is referenced by ≥1 agent prompt
|
|
12
|
+
*
|
|
13
|
+
* Pure helper with injected I/O. Returns findings + summary.
|
|
14
|
+
*
|
|
15
|
+
* ─── HS-003 Audit (PR-this) ────────────────────────────────────────────
|
|
16
|
+
* Use case distinction vs synthetic-pipeline.js:
|
|
17
|
+
* - synthetic-pipeline.js asserts "did the agent APPLY the rule when
|
|
18
|
+
* running this story?" → execution-level evidence; substring match
|
|
19
|
+
* is gameable.
|
|
20
|
+
* - pipeline-validate.js asserts "does the editor's PROMPT/scaffolding
|
|
21
|
+
* teach the rule?" → adopter-scaffolding-level coverage check;
|
|
22
|
+
* substring match is appropriate IF the rule is "topic is covered."
|
|
23
|
+
*
|
|
24
|
+
* Per-rule audit categorization (added in HS-003):
|
|
25
|
+
* - 'step-5c-ci-gate' (a) — "is Step 5c covered in the prompt?"; mention is sufficient
|
|
26
|
+
* - 'capture-learnings' (a) — "is the learnings substep documented?"; mention is sufficient
|
|
27
|
+
* - 'no-bypass-no-verify' (c) — UPGRADED: rule is FORBID, not MENTION. Substring
|
|
28
|
+
* match passed "you may use --no-verify in emergencies" (gameable). Now uses a
|
|
29
|
+
* structural check verifying negative-context proximity (forbidden/never/MUST NOT).
|
|
30
|
+
* - 'fast-track-protocol' (a) — "is the fast-track protocol explained?"; mention is sufficient
|
|
31
|
+
*
|
|
32
|
+
* Categories: (a) substring-acceptable-because-scaffolding; (b) prose-only-OK;
|
|
33
|
+
* (c) gameable-and-must-fix.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const { EDITOR_PROJECTIONS } = require('./editor-detect');
|
|
37
|
+
|
|
38
|
+
// Pipeline steps that should be covered by an agent prompt or inline section
|
|
39
|
+
const PIPELINE_STEPS = ['story-groomer', 'implementation-planner', 'unit-test-writer',
|
|
40
|
+
'e2e-qa-planner', 'e2e-test-spec-writer', 'code-builder', 'code-reviewer'];
|
|
41
|
+
|
|
42
|
+
// Canonical enforcement rules that should appear in EVERY editor's surface.
|
|
43
|
+
// Two shapes supported:
|
|
44
|
+
// - string: case-insensitive regex/substring; rule is "topic is covered"
|
|
45
|
+
// - { positive: <regex>, forbidden: <regex> }: positive must appear AND
|
|
46
|
+
// it must NOT appear in a permissive context (e.g., "you may use X")
|
|
47
|
+
const CANONICAL_RULES = Object.freeze({
|
|
48
|
+
'step-5c-ci-gate': 'Step 5c', // category (a): mention is sufficient
|
|
49
|
+
'capture-learnings': 'Capture Learnings', // category (a)
|
|
50
|
+
'no-bypass-no-verify': { // category (c): UPGRADED for HS-003
|
|
51
|
+
// Must mention the flag AND in a forbidding context (NEVER, forbidden,
|
|
52
|
+
// MUST NOT, do not). Permissive phrasings ("you may use --no-verify")
|
|
53
|
+
// fail the rule.
|
|
54
|
+
positive: '--no-verify',
|
|
55
|
+
forbidden_context: '(may|allowed|sometimes|emergency|emergencies|except|escape hatch).{0,30}--no-verify|--no-verify.{0,30}(allowed|may be used|emergency|escape hatch)',
|
|
56
|
+
// Must mention --no-verify in a forbidding context:
|
|
57
|
+
required_context: '(NEVER|never|forbidden|MUST NOT|must not|do not|don\'t|prohibited|banned).{0,80}--no-verify|--no-verify.{0,80}(forbidden|never|prohibited|banned|MUST NOT|must not)',
|
|
58
|
+
},
|
|
59
|
+
'fast-track-protocol': 'fast.?track', // category (a)
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate the SDLC pipeline scaffolding for the given editors.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} opts
|
|
66
|
+
* @param {string[]} opts.editors - detected editor keys
|
|
67
|
+
* @param {Map<string, string>} opts.surfaceContents - map: file path → content
|
|
68
|
+
* @param {(relativePath: string) => boolean} opts.fileExists
|
|
69
|
+
* @param {string[]} [opts.skillNames] - known skill names (for orphan check)
|
|
70
|
+
* @returns {{ findings: Array<{severity, category, editor?, rule?, message}>, summary }}
|
|
71
|
+
*/
|
|
72
|
+
function validatePipeline(opts = {}) {
|
|
73
|
+
const {
|
|
74
|
+
editors = [],
|
|
75
|
+
surfaceContents = new Map(),
|
|
76
|
+
fileExists,
|
|
77
|
+
skillNames = [],
|
|
78
|
+
// RR-002 + RR-004 (architect plan #117 stories 2 & 4): rule-baseline source.
|
|
79
|
+
//
|
|
80
|
+
// RR-002 default was 'canonical' (always) with warning when overlays
|
|
81
|
+
// detected. RR-004 promotes the default behavior:
|
|
82
|
+
//
|
|
83
|
+
// - source explicitly set → use it (canonical or merged)
|
|
84
|
+
// - source omitted + overlays present → default 'merged' (was: warn)
|
|
85
|
+
// - source omitted + no overlays → default 'canonical' (unchanged)
|
|
86
|
+
//
|
|
87
|
+
// The promotion eliminates the OVERLAYS_PRESENT_BUT_CANONICAL_BASELINE
|
|
88
|
+
// warning by removing the underlying gap. Adopters who previously got
|
|
89
|
+
// the warning now get correct merged validation by default.
|
|
90
|
+
//
|
|
91
|
+
// Backward compat: adopters who passed source: 'canonical' explicitly
|
|
92
|
+
// are unaffected. Adopters who relied on the implicit canonical default
|
|
93
|
+
// when overlays exist (silent canonical mode) get behavior change —
|
|
94
|
+
// this is documented in CHANGELOG.md and was the architect's risk R1.
|
|
95
|
+
repoRoot, // required when source = 'merged'
|
|
96
|
+
readFile, // required when source = 'merged'
|
|
97
|
+
overlayPaths = [], // optional list of overlay file paths
|
|
98
|
+
} = opts;
|
|
99
|
+
const sourceWasExplicit = ('source' in opts);
|
|
100
|
+
// Compute effective source AFTER destructuring (need fileExists to detect overlays)
|
|
101
|
+
let source = opts.source;
|
|
102
|
+
if (!sourceWasExplicit && typeof opts.fileExists === 'function') {
|
|
103
|
+
// RR-004 default-flip: implicit + overlays present → 'merged'
|
|
104
|
+
if (opts.fileExists('.hone-local/rule-overlays') || (opts.overlayPaths || []).length > 0) {
|
|
105
|
+
source = 'merged';
|
|
106
|
+
} else {
|
|
107
|
+
source = 'canonical';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (source === undefined) source = 'canonical';
|
|
111
|
+
const findings = [];
|
|
112
|
+
|
|
113
|
+
// ─── RR-002: derive effective rule set ───
|
|
114
|
+
// CANONICAL_RULES is the framework's hardcoded list. When source = 'merged',
|
|
115
|
+
// each adopter overlay's rule_id is added as an additional substring rule
|
|
116
|
+
// (the rule_id should appear in the editor surface so the agent knows about
|
|
117
|
+
// adopter-specific enforcement).
|
|
118
|
+
let effectiveRules = { ...CANONICAL_RULES };
|
|
119
|
+
if (source === 'merged' && typeof fileExists === 'function' && typeof readFile === 'function') {
|
|
120
|
+
try {
|
|
121
|
+
const { resolveRules } = require('./rule-resolver');
|
|
122
|
+
const resolved = resolveRules({ repoRoot, fileExists, readFile, overlayPaths });
|
|
123
|
+
for (const overlay of resolved.overlays) {
|
|
124
|
+
// Adopter overlay rule_ids become additional checks. Use the rule_id
|
|
125
|
+
// string as the substring marker — adopters expecting custom enforcement
|
|
126
|
+
// should reference the rule_id in their editor projections.
|
|
127
|
+
if (!effectiveRules[overlay.ruleId]) {
|
|
128
|
+
effectiveRules[overlay.ruleId] = overlay.ruleId;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch { /* defensive — resolveRules failure → fall back to canonical */ }
|
|
132
|
+
}
|
|
133
|
+
// RR-004: removed OVERLAYS_PRESENT_BUT_CANONICAL_BASELINE warning since the
|
|
134
|
+
// default-flip above already routes implicit + overlays-present to 'merged'.
|
|
135
|
+
// Adopters who explicitly set source: 'canonical' with overlays present are
|
|
136
|
+
// making an informed choice and don't need a warning.
|
|
137
|
+
|
|
138
|
+
if (editors.length === 0) {
|
|
139
|
+
findings.push({
|
|
140
|
+
severity: 'WARN',
|
|
141
|
+
category: 'NO_EDITORS_DETECTED',
|
|
142
|
+
message: 'No editors detected — set HONE_EDITOR env var or add editor fingerprint files',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (typeof fileExists !== 'function') {
|
|
147
|
+
return {
|
|
148
|
+
findings: [{ severity: 'ERROR', category: 'BAD_INPUT', message: 'fileExists callback missing' }],
|
|
149
|
+
summary: { error: 'missing fileExists' },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Per-editor: a. Discovery + d. Format ───
|
|
154
|
+
for (const editor of editors) {
|
|
155
|
+
const proj = EDITOR_PROJECTIONS[editor];
|
|
156
|
+
if (!proj) {
|
|
157
|
+
findings.push({
|
|
158
|
+
severity: 'WARN',
|
|
159
|
+
category: 'UNKNOWN_EDITOR',
|
|
160
|
+
editor,
|
|
161
|
+
message: `editor "${editor}" has no entry in EDITOR_PROJECTIONS table`,
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// a. Discovery — project_doc exists?
|
|
167
|
+
if (proj.project_doc && !fileExists(proj.project_doc)) {
|
|
168
|
+
findings.push({
|
|
169
|
+
severity: 'ERROR',
|
|
170
|
+
category: 'DISCOVERY',
|
|
171
|
+
editor,
|
|
172
|
+
message: `${editor} expects project_doc "${proj.project_doc}" but it doesn't exist`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// a. Discovery — agents_dir has at least one agent file?
|
|
177
|
+
if (proj.agents_dir && !fileExists(proj.agents_dir)) {
|
|
178
|
+
findings.push({
|
|
179
|
+
severity: 'ERROR',
|
|
180
|
+
category: 'DISCOVERY',
|
|
181
|
+
editor,
|
|
182
|
+
message: `${editor} expects agents_dir "${proj.agents_dir}" but it doesn't exist`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Per-editor: c. Rule presence ───
|
|
188
|
+
// For each editor's project_doc surface, verify each canonical rule appears.
|
|
189
|
+
// HS-003: rule may be a string (substring/regex) OR an object
|
|
190
|
+
// { positive, forbidden_context?, required_context? } for structural checks
|
|
191
|
+
// (e.g., "no-bypass-no-verify" — must mention --no-verify in a forbidding
|
|
192
|
+
// context; permissive phrasing fails the rule).
|
|
193
|
+
for (const editor of editors) {
|
|
194
|
+
const proj = EDITOR_PROJECTIONS[editor];
|
|
195
|
+
if (!proj || !proj.project_doc) continue;
|
|
196
|
+
const content = surfaceContents.get(proj.project_doc);
|
|
197
|
+
if (typeof content !== 'string') {
|
|
198
|
+
// #119 concern 3: don't silently skip when fileExists says yes but
|
|
199
|
+
// surfaceContents has nothing. That mismatch is a real gap (caller
|
|
200
|
+
// failed to read a file the validator was told existed).
|
|
201
|
+
if (fileExists(proj.project_doc)) {
|
|
202
|
+
findings.push({
|
|
203
|
+
severity: 'ERROR',
|
|
204
|
+
category: 'SURFACE_UNREADABLE',
|
|
205
|
+
editor,
|
|
206
|
+
message: `${editor} project_doc "${proj.project_doc}" exists per fileExists but no content provided in surfaceContents — caller should read this file before calling validatePipeline`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const [ruleId, ruleSpec] of Object.entries(effectiveRules)) {
|
|
213
|
+
const passes = evaluateRule(ruleSpec, content);
|
|
214
|
+
if (!passes.matched) {
|
|
215
|
+
findings.push({
|
|
216
|
+
severity: 'WARN',
|
|
217
|
+
category: 'RULE_MISSING',
|
|
218
|
+
editor,
|
|
219
|
+
rule: ruleId,
|
|
220
|
+
message: passes.reason
|
|
221
|
+
? `rule "${ruleId}" not satisfied in ${editor}'s project_doc (${proj.project_doc}): ${passes.reason}`
|
|
222
|
+
: `rule "${ruleId}" not found in ${editor}'s project_doc (${proj.project_doc})`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Cross-editor: e. Drift detection ───
|
|
229
|
+
if (editors.length >= 2) {
|
|
230
|
+
// For each rule, count how many editors' surfaces have it
|
|
231
|
+
for (const [ruleId, ruleSpec] of Object.entries(effectiveRules)) {
|
|
232
|
+
const editorsWithRule = [];
|
|
233
|
+
const editorsWithoutRule = [];
|
|
234
|
+
for (const editor of editors) {
|
|
235
|
+
const proj = EDITOR_PROJECTIONS[editor];
|
|
236
|
+
if (!proj || !proj.project_doc) continue;
|
|
237
|
+
const content = surfaceContents.get(proj.project_doc);
|
|
238
|
+
if (typeof content !== 'string') continue;
|
|
239
|
+
if (evaluateRule(ruleSpec, content).matched) editorsWithRule.push(editor);
|
|
240
|
+
else editorsWithoutRule.push(editor);
|
|
241
|
+
}
|
|
242
|
+
// Drift: some editors have it, some don't
|
|
243
|
+
if (editorsWithRule.length > 0 && editorsWithoutRule.length > 0) {
|
|
244
|
+
findings.push({
|
|
245
|
+
severity: 'ERROR',
|
|
246
|
+
category: 'DRIFT',
|
|
247
|
+
rule: ruleId,
|
|
248
|
+
message: `rule "${ruleId}" present in [${editorsWithRule.join(',')}] but missing from [${editorsWithoutRule.join(',')}] — drift between editor projections`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Cross-editor: f. Skill referenced ───
|
|
255
|
+
// For each skill, verify ≥1 surface mentions it.
|
|
256
|
+
for (const skillName of skillNames) {
|
|
257
|
+
let mentioned = false;
|
|
258
|
+
for (const content of surfaceContents.values()) {
|
|
259
|
+
if (typeof content === 'string' && content.includes(skillName)) {
|
|
260
|
+
mentioned = true;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!mentioned) {
|
|
265
|
+
findings.push({
|
|
266
|
+
severity: 'INFO',
|
|
267
|
+
category: 'ORPHAN_SKILL',
|
|
268
|
+
message: `skill "${skillName}" not referenced by any editor surface — possibly orphaned`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const summary = {
|
|
274
|
+
editors_validated: editors.length,
|
|
275
|
+
surfaces_scanned: surfaceContents.size,
|
|
276
|
+
error_count: findings.filter((f) => f.severity === 'ERROR').length,
|
|
277
|
+
warn_count: findings.filter((f) => f.severity === 'WARN').length,
|
|
278
|
+
info_count: findings.filter((f) => f.severity === 'INFO').length,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
return { findings, summary };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Evaluate one CANONICAL_RULES entry against a content string.
|
|
286
|
+
* (HS-003) Handles both shapes:
|
|
287
|
+
* - string: case-insensitive regex/substring match
|
|
288
|
+
* - object { positive, forbidden_context?, required_context? }: structural
|
|
289
|
+
* check requiring positive AND (if specified) required context AND
|
|
290
|
+
* no forbidden context.
|
|
291
|
+
*
|
|
292
|
+
* @param {string|object} ruleSpec
|
|
293
|
+
* @param {string} content
|
|
294
|
+
* @returns {{ matched: boolean, reason?: string }}
|
|
295
|
+
*/
|
|
296
|
+
function evaluateRule(ruleSpec, content) {
|
|
297
|
+
if (typeof ruleSpec === 'string') {
|
|
298
|
+
return { matched: new RegExp(ruleSpec, 'i').test(content) };
|
|
299
|
+
}
|
|
300
|
+
if (!ruleSpec || typeof ruleSpec !== 'object') {
|
|
301
|
+
return { matched: false, reason: 'invalid rule spec' };
|
|
302
|
+
}
|
|
303
|
+
// Structural rule
|
|
304
|
+
const { positive, forbidden_context, required_context } = ruleSpec;
|
|
305
|
+
if (positive && !new RegExp(positive, 'i').test(content)) {
|
|
306
|
+
return { matched: false, reason: 'positive marker missing' };
|
|
307
|
+
}
|
|
308
|
+
if (forbidden_context && new RegExp(forbidden_context, 'i').test(content)) {
|
|
309
|
+
return { matched: false, reason: 'rule appears in a permissive context (e.g., "may use" or "emergency"); rule is FORBID, not MENTION' };
|
|
310
|
+
}
|
|
311
|
+
if (required_context && !new RegExp(required_context, 'i').test(content)) {
|
|
312
|
+
return { matched: false, reason: 'rule mentioned but not in a forbidding context (NEVER, forbidden, MUST NOT, prohibited)' };
|
|
313
|
+
}
|
|
314
|
+
return { matched: true };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
evaluateRule,
|
|
319
|
+
PIPELINE_STEPS,
|
|
320
|
+
CANONICAL_RULES,
|
|
321
|
+
validatePipeline,
|
|
322
|
+
};
|