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