@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,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* derive-domain.js — HC-004 (first per-adopter skill scaffolder).
|
|
4
|
+
*
|
|
5
|
+
* Pure helper that scaffolds a per-adopter `<name>-domain/SKILL.md` from
|
|
6
|
+
* the 7-section template documented in pipeline-integrity §13 (Per-adopter
|
|
7
|
+
* `<domain>-domain/SKILL.md` slot, OptionsFlow E26-A-L1).
|
|
8
|
+
*
|
|
9
|
+
* Marker discipline (per HC-001-L1):
|
|
10
|
+
* - Frontmatter `managed_by: hone-derive-domain` marks the file as
|
|
11
|
+
* framework-scaffolded
|
|
12
|
+
* - Three states: absent → write; managed → no-op; unmanaged → SKIP
|
|
13
|
+
* unless --force
|
|
14
|
+
*
|
|
15
|
+
* Uses static template at cli/lib/domain-skill-template.md (per HC-001's
|
|
16
|
+
* static-file precedent — auditable, previewable, lintable).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const TEMPLATE_PATH = path.join(__dirname, 'domain-skill-template.md');
|
|
23
|
+
const MANAGED_MARKER = 'managed_by: hone-derive-domain';
|
|
24
|
+
|
|
25
|
+
const DOMAIN_SKILL_SECTIONS = [
|
|
26
|
+
'Vocabulary',
|
|
27
|
+
'Architectural Contracts',
|
|
28
|
+
'Review Rules',
|
|
29
|
+
'Anti-Hallucination Rules',
|
|
30
|
+
'Calibration & Empirical Unknowns',
|
|
31
|
+
'Known Asymmetries / Bugs',
|
|
32
|
+
'References',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const RESERVED_NAMES = new Set([
|
|
36
|
+
'domain', // would produce 'domain-domain' — silly
|
|
37
|
+
'skill', // could be confused with skill itself
|
|
38
|
+
'skills',
|
|
39
|
+
'core',
|
|
40
|
+
'common',
|
|
41
|
+
'global',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} name
|
|
46
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
47
|
+
*/
|
|
48
|
+
function validateDomainName(name) {
|
|
49
|
+
if (!name || typeof name !== 'string') {
|
|
50
|
+
return { ok: false, error: 'domainName is required (non-empty string)' };
|
|
51
|
+
}
|
|
52
|
+
if (name.length < 2) {
|
|
53
|
+
return { ok: false, error: `domainName too short: "${name}" (min 2 chars)` };
|
|
54
|
+
}
|
|
55
|
+
if (name.length > 50) {
|
|
56
|
+
return { ok: false, error: `domainName too long: "${name}" (max 50 chars)` };
|
|
57
|
+
}
|
|
58
|
+
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(name)) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `domainName must be kebab-case (lowercase a-z + 0-9 + hyphens; start and end with alphanumeric): "${name}"`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (RESERVED_NAMES.has(name)) {
|
|
65
|
+
return { ok: false, error: `domainName is reserved: "${name}"` };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Scaffold a per-adopter domain skill.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} args
|
|
74
|
+
* @param {string} args.repoRoot
|
|
75
|
+
* @param {string} args.domainName
|
|
76
|
+
* @param {boolean} [args.force]
|
|
77
|
+
* @param {Function} [args.now] () => new Date()
|
|
78
|
+
* @returns {{
|
|
79
|
+
* findings: Array,
|
|
80
|
+
* summary: { total, errors, warnings, info },
|
|
81
|
+
* created: string[],
|
|
82
|
+
* skipped: Array<{path, reason}>,
|
|
83
|
+
* }}
|
|
84
|
+
*/
|
|
85
|
+
function deriveDomain(args) {
|
|
86
|
+
const { repoRoot, domainName, force = false } = args || {};
|
|
87
|
+
const now = args && args.now ? args.now : () => new Date();
|
|
88
|
+
const findings = [];
|
|
89
|
+
const created = [];
|
|
90
|
+
const skipped = [];
|
|
91
|
+
|
|
92
|
+
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
93
|
+
return wrap(
|
|
94
|
+
[{ severity: 'ERROR', code: 'invalid-args', message: 'repoRoot is required' }],
|
|
95
|
+
created, skipped,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const valid = validateDomainName(domainName);
|
|
100
|
+
if (!valid.ok) {
|
|
101
|
+
return wrap(
|
|
102
|
+
[{ severity: 'ERROR', code: 'invalid-domain-name', message: valid.error }],
|
|
103
|
+
created, skipped,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!fs.existsSync(TEMPLATE_PATH)) {
|
|
108
|
+
return wrap(
|
|
109
|
+
[{ severity: 'ERROR', code: 'template-missing', message: `template not found: ${TEMPLATE_PATH}` }],
|
|
110
|
+
created, skipped,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const targetDir = path.join(repoRoot, '.github/skills', `${domainName}-domain`);
|
|
115
|
+
const targetFile = path.join(targetDir, 'SKILL.md');
|
|
116
|
+
|
|
117
|
+
// Three states per HC-001-L1
|
|
118
|
+
if (fs.existsSync(targetFile)) {
|
|
119
|
+
const existing = fs.readFileSync(targetFile, 'utf8');
|
|
120
|
+
const isManaged = existing.includes(MANAGED_MARKER);
|
|
121
|
+
if (isManaged && !force) {
|
|
122
|
+
skipped.push({
|
|
123
|
+
path: targetFile,
|
|
124
|
+
reason: 'already-managed — re-run is a no-op (idempotent); pass --force to overwrite',
|
|
125
|
+
});
|
|
126
|
+
return wrap(findings, created, skipped);
|
|
127
|
+
}
|
|
128
|
+
if (!isManaged && !force) {
|
|
129
|
+
skipped.push({
|
|
130
|
+
path: targetFile,
|
|
131
|
+
reason: 'unmanaged-existing-file — refusing to overwrite adopter customization',
|
|
132
|
+
userAction: 'pass --force to overwrite, or remove the file manually first',
|
|
133
|
+
});
|
|
134
|
+
return wrap(findings, created, skipped);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Render template
|
|
139
|
+
let template;
|
|
140
|
+
try {
|
|
141
|
+
template = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return wrap(
|
|
144
|
+
[{ severity: 'ERROR', code: 'template-read-failed', message: e.message }],
|
|
145
|
+
created, skipped,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const derivedAt = now().toISOString();
|
|
150
|
+
const content = template
|
|
151
|
+
.replace(/\{\{DOMAIN_NAME\}\}/g, domainName)
|
|
152
|
+
.replace(/\{\{DERIVED_AT\}\}/g, derivedAt);
|
|
153
|
+
|
|
154
|
+
// Write
|
|
155
|
+
try {
|
|
156
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
157
|
+
fs.writeFileSync(targetFile, content, 'utf8');
|
|
158
|
+
created.push(targetFile);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return wrap(
|
|
161
|
+
[{ severity: 'ERROR', code: 'write-failed', message: `failed to write ${targetFile}: ${e.message}` }],
|
|
162
|
+
created, skipped,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return wrap(findings, created, skipped);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function wrap(findings, created, skipped) {
|
|
170
|
+
const summary = {
|
|
171
|
+
total: findings.length,
|
|
172
|
+
errors: findings.filter(f => f.severity === 'ERROR').length,
|
|
173
|
+
warnings: findings.filter(f => f.severity === 'WARN').length,
|
|
174
|
+
info: findings.filter(f => f.severity === 'INFO').length,
|
|
175
|
+
};
|
|
176
|
+
return { findings, summary, created, skipped };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
deriveDomain,
|
|
181
|
+
validateDomainName,
|
|
182
|
+
DOMAIN_SKILL_SECTIONS,
|
|
183
|
+
MANAGED_MARKER,
|
|
184
|
+
RESERVED_NAMES,
|
|
185
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* doc-registry.js — Platform doc registry URL selection helper (HC-011).
|
|
4
|
+
*
|
|
5
|
+
* Pure helper (no I/O). Takes a parsed registry YAML object and a list
|
|
6
|
+
* of discovered metadata types, returns an ordered list of URLs filtered
|
|
7
|
+
* by relevance.
|
|
8
|
+
*
|
|
9
|
+
* Architecture: docs/architecture/platform-auto-discovery-v1.md (Tier 2)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MAX_URLS = 40; // 40 × 10K chars = 400K max context
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Select registry URLs relevant to the discovered metadata types.
|
|
16
|
+
*
|
|
17
|
+
* @param {object|null} registry - parsed platform doc registry YAML
|
|
18
|
+
* @param {string[]|null} discoveredTypes - metadata type names from platform-discover.js
|
|
19
|
+
* @returns {Array<{url: string, title: string, category: string, priority: number}>}
|
|
20
|
+
*/
|
|
21
|
+
function selectRegistryUrls(registry, discoveredTypes) {
|
|
22
|
+
if (!registry || typeof registry !== 'object') return [];
|
|
23
|
+
const categories = registry.doc_categories;
|
|
24
|
+
if (!categories || typeof categories !== 'object') return [];
|
|
25
|
+
|
|
26
|
+
const types = Array.isArray(discoveredTypes) ? discoveredTypes : [];
|
|
27
|
+
const typesSet = new Set(types);
|
|
28
|
+
const selected = [];
|
|
29
|
+
|
|
30
|
+
for (const [category, entries] of Object.entries(categories)) {
|
|
31
|
+
if (!Array.isArray(entries)) continue;
|
|
32
|
+
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (!entry || !entry.url || !entry.title) continue;
|
|
35
|
+
|
|
36
|
+
const relevance = entry.relevance;
|
|
37
|
+
let relevant = false;
|
|
38
|
+
|
|
39
|
+
if (relevance === 'always') {
|
|
40
|
+
relevant = true;
|
|
41
|
+
} else if (Array.isArray(relevance)) {
|
|
42
|
+
relevant = relevance.some(r => typesSet.has(r));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (relevant) {
|
|
46
|
+
selected.push({
|
|
47
|
+
url: entry.url,
|
|
48
|
+
title: entry.title,
|
|
49
|
+
category,
|
|
50
|
+
priority: entry.priority || 2,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Sort: priority 1 first, then by category order (stable sort preserves insertion order)
|
|
57
|
+
selected.sort((a, b) => a.priority - b.priority);
|
|
58
|
+
|
|
59
|
+
// Cap at MAX_URLS
|
|
60
|
+
return selected.slice(0, MAX_URLS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { selectRegistryUrls, MAX_URLS };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* doctor-admin-merge.js — HC-002 + HC-005-A.
|
|
4
|
+
*
|
|
5
|
+
* Scans recent git history for the admin-merge anti-pattern (per E13-A-L3,
|
|
6
|
+
* absorbed into pr-review-standards/SKILL.md §Block admin-merge).
|
|
7
|
+
*
|
|
8
|
+
* Detects commits whose message contains:
|
|
9
|
+
* - --admin
|
|
10
|
+
* - --no-verify
|
|
11
|
+
* - "force merge" / "force push" / "force-merge"
|
|
12
|
+
* - "bypass" (CI / hook / branch protection)
|
|
13
|
+
*
|
|
14
|
+
* HC-005-A self-referential check discipline (per pipeline-integrity §17.5):
|
|
15
|
+
* - Subject-only scan by default; body fall-through gated by meta-words.
|
|
16
|
+
* - `[doctor:meta]` marker exempts a commit (subject OR body) from scan.
|
|
17
|
+
* - Shallow-clone detection → `info` instead of silent `ok`.
|
|
18
|
+
*
|
|
19
|
+
* Returns the conventional doctor result shape: {name, status, reason, suggestedFix}.
|
|
20
|
+
* Pure-helper-with-injected-IO style; never throws.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const { execSync } = require('node:child_process');
|
|
26
|
+
|
|
27
|
+
// HC-005-A: ambiguous pattern named explicitly so the ambiguity-guard
|
|
28
|
+
// doesn't depend on array index ordering. Adding new patterns to
|
|
29
|
+
// ADMIN_MERGE_PATTERNS won't accidentally re-target the ambiguity guard.
|
|
30
|
+
const BYPASS_PATTERN = /\bbypass(es|ed|ing|ing-)?\b/i;
|
|
31
|
+
|
|
32
|
+
// Patterns that indicate admin-merge / hook-bypass behavior
|
|
33
|
+
const ADMIN_MERGE_PATTERNS = [
|
|
34
|
+
/--admin\b/i,
|
|
35
|
+
/--no-verify\b/i,
|
|
36
|
+
/\bforce[ -]merge/i,
|
|
37
|
+
/\bforce push\b/i,
|
|
38
|
+
BYPASS_PATTERN, // "bypassed CI", "bypass branch protection" — ambiguous; guarded
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// HC-005-A: explicit marker exempts a commit (intended for meta-commits that
|
|
42
|
+
// document / forbid the patterns being scanned for)
|
|
43
|
+
const META_MARKER = '[doctor:meta]';
|
|
44
|
+
|
|
45
|
+
// HC-005-A: words that strongly suggest a commit body is *discussing* the
|
|
46
|
+
// patterns rather than *applying* them. Used to filter body-only hits.
|
|
47
|
+
// Subject hits always count, regardless of these words.
|
|
48
|
+
const META_DISCUSSION_RE = /\b(regex|patterns?|detects?|forbid(s|den)?|enforce(s|d)?|bans?|blocks?|prohibit(s|ed)?|documents?|rules?|prevent(s|ed)?|no-bypass|no-admin|no-no-verify)\b/i;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} args
|
|
52
|
+
* @param {string} args.repoRoot
|
|
53
|
+
* @param {number} [args.lookback=50] commits to scan
|
|
54
|
+
* @returns {{ name: 'admin-merge', status: 'ok'|'drift'|'skip'|'info', reason: string, suggestedFix?: string }}
|
|
55
|
+
*/
|
|
56
|
+
function checkAdminMerge(args) {
|
|
57
|
+
const { repoRoot, lookback = 50 } = args || {};
|
|
58
|
+
const name = 'admin-merge';
|
|
59
|
+
|
|
60
|
+
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
61
|
+
return { name, status: 'skip', reason: 'no repoRoot supplied' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Verify it's a git repo
|
|
65
|
+
let log;
|
|
66
|
+
try {
|
|
67
|
+
log = execSync(`git log -n ${lookback} --pretty=format:'%H%x09%s%x09%b%x1e'`, {
|
|
68
|
+
cwd: repoRoot,
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
return { name, status: 'skip', reason: 'not a git repository (or no commits yet)' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Split on record separator
|
|
77
|
+
const commits = log.split('\x1e').filter(c => c.trim());
|
|
78
|
+
|
|
79
|
+
// HC-005-A: shallow-clone detection — adopters with `clone --depth N`
|
|
80
|
+
// would get a silent `ok` after scanning only N commits. Surface it as
|
|
81
|
+
// `info` so they know the audit is partial.
|
|
82
|
+
const isShallow = isShallowClone(repoRoot);
|
|
83
|
+
|
|
84
|
+
const offenders = [];
|
|
85
|
+
for (const c of commits) {
|
|
86
|
+
const [sha, subject = '', body = ''] = c.split('\t');
|
|
87
|
+
const cleanSubject = subject.trim();
|
|
88
|
+
const cleanBody = body.trim();
|
|
89
|
+
|
|
90
|
+
// HC-005-A (a): explicit meta-marker exemption
|
|
91
|
+
if (cleanSubject.includes(META_MARKER) || cleanBody.includes(META_MARKER)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// HC-005-A (b): two-stage filter
|
|
96
|
+
// Stage 1 — subject hit. Flag-form patterns (--admin, --no-verify,
|
|
97
|
+
// force-merge, force push) are unambiguous in subjects. The standalone
|
|
98
|
+
// `bypass` token is ambiguous (e.g., "no-bypass rule" describes a feature
|
|
99
|
+
// that BANS bypass). For ambiguous subject matches, apply the
|
|
100
|
+
// discussion-words filter; for unambiguous matches, count immediately.
|
|
101
|
+
let matchedRe = null;
|
|
102
|
+
for (const re of ADMIN_MERGE_PATTERNS) {
|
|
103
|
+
if (re.test(cleanSubject)) {
|
|
104
|
+
if (re === BYPASS_PATTERN && META_DISCUSSION_RE.test(cleanSubject)) {
|
|
105
|
+
// Subject discusses the pattern (e.g., "no-bypass rule") — skip this match
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
matchedRe = re;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Stage 2 — body fall-through, but only if body lacks meta-discussion words
|
|
114
|
+
if (!matchedRe) {
|
|
115
|
+
for (const re of ADMIN_MERGE_PATTERNS) {
|
|
116
|
+
if (re.test(cleanBody)) {
|
|
117
|
+
if (!META_DISCUSSION_RE.test(cleanBody)) {
|
|
118
|
+
matchedRe = re;
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (matchedRe) {
|
|
126
|
+
offenders.push({
|
|
127
|
+
sha: sha.trim().slice(0, 7),
|
|
128
|
+
pattern: matchedRe.source,
|
|
129
|
+
subject: cleanSubject.slice(0, 80),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (offenders.length === 0) {
|
|
135
|
+
if (isShallow) {
|
|
136
|
+
return {
|
|
137
|
+
name,
|
|
138
|
+
status: 'info',
|
|
139
|
+
reason: `shallow clone — scanned only ${commits.length} commits; \`git fetch --unshallow\` for deeper audit`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
name,
|
|
144
|
+
status: 'ok',
|
|
145
|
+
reason: `no admin-merge anti-patterns in last ${commits.length} commits`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const detail = offenders.map(o => `${o.sha} (${o.pattern}): ${o.subject}`).join('; ');
|
|
150
|
+
return {
|
|
151
|
+
name,
|
|
152
|
+
status: 'drift',
|
|
153
|
+
reason: `Found ${offenders.length} admin-merge anti-pattern commit(s) in last ${commits.length}: ${detail.slice(0, 300)}${detail.length > 300 ? '…' : ''}`,
|
|
154
|
+
suggestedFix: 'Branch protection should block --admin (per E13-A-L3). See pr-review-standards/SKILL.md §Block admin-merge anti-pattern. For commits documenting the pattern (not applying it), add [doctor:meta] to the subject or body.',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Detect shallow clone via `git rev-parse --is-shallow-repository`.
|
|
160
|
+
* This handles regular repos, worktrees (where `.git` is a file), and
|
|
161
|
+
* submodules — all cases the path-based `.git/shallow` check would miss.
|
|
162
|
+
*
|
|
163
|
+
* Falls back to checking `.git/shallow` if git is unavailable / errors.
|
|
164
|
+
* Returns false on any error (skips the surfacing — better than false-positiving).
|
|
165
|
+
*/
|
|
166
|
+
function isShallowClone(repoRoot) {
|
|
167
|
+
try {
|
|
168
|
+
const out = execSync('git rev-parse --is-shallow-repository', {
|
|
169
|
+
cwd: repoRoot,
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
172
|
+
}).trim();
|
|
173
|
+
return out === 'true';
|
|
174
|
+
} catch {
|
|
175
|
+
// Fallback to file-based detection
|
|
176
|
+
try {
|
|
177
|
+
const shallowPath = path.join(repoRoot, '.git', 'shallow');
|
|
178
|
+
return fs.existsSync(shallowPath);
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { checkAdminMerge, ADMIN_MERGE_PATTERNS, META_MARKER, META_DISCUSSION_RE };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* doctor-bind-default.js — HC-002.
|
|
4
|
+
*
|
|
5
|
+
* Greps source code for the literal `0.0.0.0` bind anti-pattern (per
|
|
6
|
+
* E13-A-L1, absorbed into reliability/SKILL.md §Bind to localhost +
|
|
7
|
+
* Hard Rules). Per SC-006-L1's prescriptive-language discipline, the
|
|
8
|
+
* `reason` field uses MUST NOT / never wording so the check itself
|
|
9
|
+
* models the rule it enforces.
|
|
10
|
+
*
|
|
11
|
+
* Filters out:
|
|
12
|
+
* - Test fixtures (tests/, fixtures/, *.test.*)
|
|
13
|
+
* - Documentation (docs/, *.md)
|
|
14
|
+
* - Hook templates (cli/lib/hook-templates/)
|
|
15
|
+
* - node_modules / .git / dist / build
|
|
16
|
+
* - Lines containing env-var-driven binds (os.getenv / process.env)
|
|
17
|
+
*
|
|
18
|
+
* Returns conventional doctor result shape. Never throws.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const { execSync } = require('node:child_process');
|
|
24
|
+
|
|
25
|
+
const DEFAULT_ALLOWED_PATH_PREFIXES = [
|
|
26
|
+
'tests/',
|
|
27
|
+
'test/',
|
|
28
|
+
'fixtures/',
|
|
29
|
+
'docs/',
|
|
30
|
+
'node_modules/',
|
|
31
|
+
'.git/',
|
|
32
|
+
'dist/',
|
|
33
|
+
'build/',
|
|
34
|
+
'cli/lib/hook-templates/', // pre-commit/pre-push templates may reference 0.0.0.0 in comments
|
|
35
|
+
'.github/learnings/', // narrative may quote the anti-pattern
|
|
36
|
+
'.github/pipeline/', // story artifacts may quote the anti-pattern
|
|
37
|
+
'server/seeds/skills/', // skill content may quote the rule
|
|
38
|
+
'server/seeds/learnings/',
|
|
39
|
+
'enterprise-assets/.github/learnings/',
|
|
40
|
+
'.github/skills/',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Files to ALSO skip by extension
|
|
44
|
+
const SKIP_EXT = ['.md', '.test.js', '.test.ts', '.test.py', '.spec.js'];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} args
|
|
48
|
+
* @param {string} args.repoRoot
|
|
49
|
+
* @param {string[]} [args.allowedPaths] path prefixes to skip (default: DEFAULT_ALLOWED_PATH_PREFIXES)
|
|
50
|
+
* @returns {{ name: 'bind-default', status: 'ok'|'drift'|'skip', reason: string, suggestedFix?: string }}
|
|
51
|
+
*/
|
|
52
|
+
function checkBindDefault(args) {
|
|
53
|
+
const { repoRoot, allowedPaths = DEFAULT_ALLOWED_PATH_PREFIXES } = args || {};
|
|
54
|
+
const name = 'bind-default';
|
|
55
|
+
|
|
56
|
+
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
57
|
+
return { name, status: 'skip', reason: 'no repoRoot supplied' };
|
|
58
|
+
}
|
|
59
|
+
if (!fs.existsSync(repoRoot)) {
|
|
60
|
+
return { name, status: 'skip', reason: `repoRoot does not exist: ${repoRoot}` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// grep -rE "0\.0\.0\.0" with file exclusions
|
|
64
|
+
let raw = '';
|
|
65
|
+
try {
|
|
66
|
+
raw = execSync(
|
|
67
|
+
`grep -rEn '0\\.0\\.0\\.0' . ` +
|
|
68
|
+
`--include='*.py' --include='*.js' --include='*.ts' --include='*.go' --include='*.rb' --include='*.java' --include='*.kt' ` +
|
|
69
|
+
`--include='*.yml' --include='*.yaml' --include='*.toml' --include='*.json' --include='*.sh' ` +
|
|
70
|
+
`2>/dev/null || true`,
|
|
71
|
+
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 4 * 1024 * 1024 },
|
|
72
|
+
);
|
|
73
|
+
} catch {
|
|
74
|
+
return { name, status: 'skip', reason: 'grep failed (or no source files matched)' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const offenders = [];
|
|
78
|
+
for (const line of raw.split('\n')) {
|
|
79
|
+
if (!line.trim()) continue;
|
|
80
|
+
// Format: ./path/to/file:LINENO:matched-line
|
|
81
|
+
const m = line.match(/^\.\/(.+?):(\d+):(.*)$/);
|
|
82
|
+
if (!m) continue;
|
|
83
|
+
const [, file, lineNo, content] = m;
|
|
84
|
+
|
|
85
|
+
// Filter out allowed paths
|
|
86
|
+
if (allowedPaths.some(p => file.startsWith(p))) continue;
|
|
87
|
+
// Filter out test/spec files by extension
|
|
88
|
+
if (SKIP_EXT.some(ext => file.endsWith(ext))) continue;
|
|
89
|
+
// Filter out env-var-driven binds (legitimate use)
|
|
90
|
+
if (/\bos\.getenv\b|\bprocess\.env\b|\bgetenv\(/.test(content)) continue;
|
|
91
|
+
// Filter out lines that are clearly comments mentioning the anti-pattern
|
|
92
|
+
if (/^\s*(#|\/\/|--).*0\.0\.0\.0/.test(content)) continue;
|
|
93
|
+
|
|
94
|
+
offenders.push({ file, line: lineNo, content: content.trim().slice(0, 100) });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (offenders.length === 0) {
|
|
98
|
+
return {
|
|
99
|
+
name,
|
|
100
|
+
status: 'ok',
|
|
101
|
+
reason: 'no literal 0.0.0.0 binds found in production code',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Prescriptive language per SC-006-L1
|
|
106
|
+
const detail = offenders.slice(0, 5).map(o => `${o.file}:${o.line}`).join(', ');
|
|
107
|
+
return {
|
|
108
|
+
name,
|
|
109
|
+
status: 'drift',
|
|
110
|
+
reason: `MUST NOT bind 0.0.0.0 from code (per E13-A-L1). Never default to a wildcard host; always read from BIND_HOST env-var with 127.0.0.1 fallback. Found ${offenders.length} violation(s): ${detail}${offenders.length > 5 ? ` (+${offenders.length - 5} more)` : ''}`,
|
|
111
|
+
suggestedFix: 'Use os.getenv("BIND_HOST", "127.0.0.1") or process.env.BIND_HOST || "127.0.0.1". See reliability/SKILL.md §Bind to localhost by default.',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
checkBindDefault,
|
|
117
|
+
DEFAULT_ALLOWED_PATH_PREFIXES,
|
|
118
|
+
};
|