jhste-skills 0.1.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/LICENSE +21 -0
- package/README.ja.md +254 -0
- package/README.ko.md +254 -0
- package/README.md +254 -0
- package/README.zh.md +254 -0
- package/adapters/claude/README.md +7 -0
- package/adapters/codex/README.md +25 -0
- package/adapters/generic/README.md +7 -0
- package/cli/baseline.mjs +32 -0
- package/cli/connect.mjs +84 -0
- package/cli/deep-scan/analyze.mjs +167 -0
- package/cli/deep-scan/collect.mjs +133 -0
- package/cli/deep-scan/report.mjs +197 -0
- package/cli/deep-scan.mjs +56 -0
- package/cli/guard/baseline.mjs +64 -0
- package/cli/guard/config.mjs +48 -0
- package/cli/guard/profile-commands.mjs +87 -0
- package/cli/guard/registry.mjs +47 -0
- package/cli/guard/reporting.mjs +165 -0
- package/cli/guard/scanners/code-health.mjs +213 -0
- package/cli/guard/scanners/data-boundary-locality.mjs +125 -0
- package/cli/guard/scanners/data-boundary.mjs +237 -0
- package/cli/guard/scanners/external-input.mjs +74 -0
- package/cli/guard/scanners/index.mjs +136 -0
- package/cli/guard/scanners/single-responsibility.mjs +205 -0
- package/cli/guard/scanners/ui-runtime.mjs +140 -0
- package/cli/guard/scanners/utils.mjs +167 -0
- package/cli/guard/scope.mjs +181 -0
- package/cli/guard.mjs +125 -0
- package/cli/hook-utils.mjs +127 -0
- package/cli/hooks.mjs +127 -0
- package/cli/index.mjs +35 -0
- package/cli/install-actions/apply-plan.mjs +39 -0
- package/cli/install-actions/bridge-writer.mjs +52 -0
- package/cli/install-actions/output.mjs +45 -0
- package/cli/install-actions/preflight.mjs +58 -0
- package/cli/install-actions/profile-writer.mjs +21 -0
- package/cli/install-actions/skills.mjs +148 -0
- package/cli/install-actions.mjs +4 -0
- package/cli/install-flow/options.mjs +234 -0
- package/cli/install-flow/output.mjs +106 -0
- package/cli/install-flow/plan-helpers.mjs +29 -0
- package/cli/install-flow/plan.mjs +200 -0
- package/cli/install-flow/prompts.mjs +210 -0
- package/cli/install-flow.mjs +16 -0
- package/cli/install.mjs +77 -0
- package/cli/json-file.mjs +39 -0
- package/cli/profile/loader.mjs +13 -0
- package/cli/profile/parser.mjs +226 -0
- package/cli/profile/schema.mjs +81 -0
- package/cli/profile/settings.mjs +45 -0
- package/cli/profile/validator.mjs +86 -0
- package/cli/profile.mjs +5 -0
- package/cli/shared/args.mjs +32 -0
- package/cli/shared/files.mjs +70 -0
- package/cli/shared/git.mjs +28 -0
- package/cli/shared/paths.mjs +27 -0
- package/cli/shared/prompt.mjs +32 -0
- package/cli/shared/templates.mjs +71 -0
- package/cli/shared/time.mjs +3 -0
- package/cli/shared.mjs +7 -0
- package/cli/sync-core.mjs +213 -0
- package/cli/sync.mjs +7 -0
- package/cli/tune.mjs +101 -0
- package/cli/uninstall.mjs +288 -0
- package/cli/update.mjs +7 -0
- package/docs/ACCEPTANCE_CHECK.md +54 -0
- package/docs/CLI.md +212 -0
- package/docs/CONFLICT_RESOLUTION.md +58 -0
- package/docs/PUBLIC_SAFETY.md +26 -0
- package/docs/RULES.md +94 -0
- package/docs/VENDORING.md +23 -0
- package/examples/profile.yaml +45 -0
- package/package.json +51 -0
- package/packs/api.yaml +13 -0
- package/packs/core.yaml +19 -0
- package/packs/crawler.yaml +8 -0
- package/packs/database.yaml +8 -0
- package/packs/web.yaml +10 -0
- package/rules/core/api_contract_compatibility.yaml +25 -0
- package/rules/core/authz_data_isolation.yaml +27 -0
- package/rules/core/build_runtime_env_safety.yaml +26 -0
- package/rules/core/external_input_validation.yaml +27 -0
- package/rules/core/file_size_advisory.yaml +28 -0
- package/rules/core/no_secret_logging.yaml +24 -0
- package/rules/core/no_silent_failure.yaml +30 -0
- package/rules/core/null_state_safety.yaml +25 -0
- package/rules/core/performance_duplicate_fetch.yaml +25 -0
- package/rules/core/public_safe_error.yaml +24 -0
- package/rules/core/responsibility_budget.yaml +44 -0
- package/rules/core/side_effect_boundary.yaml +24 -0
- package/rules/core/single_responsibility_advisory.yaml +35 -0
- package/rules/core/workflow_security.yaml +25 -0
- package/rules/core/write_safety_idempotency.yaml +25 -0
- package/rules/crawler/crawler_producer_boundary.yaml +24 -0
- package/rules/database/db_row_validation.yaml +24 -0
- package/rules/database/sql_parameter_binding.yaml +24 -0
- package/rules/nextjs/thin_api_route.yaml +24 -0
- package/rules/python/broad_exception_advisory.yaml +24 -0
- package/rules/react/component_responsibility.yaml +24 -0
- package/rules/typescript/type_escape_advisory.yaml +24 -0
- package/scripts/docs-check-data.mjs +71 -0
- package/scripts/docs-check.mjs +261 -0
- package/scripts/guard-fixtures/helpers.mjs +58 -0
- package/scripts/guard-fixtures-test.mjs +273 -0
- package/scripts/profile-fixtures-test.mjs +83 -0
- package/scripts/public-safety-check.mjs +88 -0
- package/scripts/public-safety-fixtures-test.mjs +60 -0
- package/scripts/release-gates-test.mjs +52 -0
- package/scripts/single-responsibility-fixtures-test.mjs +86 -0
- package/scripts/smoke/connect-scenarios.mjs +47 -0
- package/scripts/smoke/fixture.mjs +49 -0
- package/scripts/smoke/guard-and-hook-scenarios.mjs +211 -0
- package/scripts/smoke/helpers.mjs +51 -0
- package/scripts/smoke/install-scenarios.mjs +244 -0
- package/scripts/smoke/mode-scenarios.mjs +76 -0
- package/scripts/smoke-test.mjs +17 -0
- package/scripts/syntax-check.mjs +37 -0
- package/scripts/vendor-check.mjs +87 -0
- package/skills/codebase-design/DEEPENING.md +37 -0
- package/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/skills/codebase-design/SKILL.md +122 -0
- package/skills/diagnose/SKILL.md +125 -0
- package/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/skills/diagnosing-bugs/SKILL.md +142 -0
- package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
- package/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/skills/domain-modeling/SKILL.md +82 -0
- package/skills/grill-me/SKILL.md +18 -0
- package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
- package/skills/grill-with-docs/SKILL.md +96 -0
- package/skills/grilling/SKILL.md +18 -0
- package/skills/handoff/SKILL.md +23 -0
- package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
- package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/skills/improve-codebase-architecture/SKILL.md +93 -0
- package/skills/jhste-architecture-review/SKILL.md +28 -0
- package/skills/jhste-architecture-review/references/architecture-review.md +41 -0
- package/skills/jhste-code-quality/SKILL.md +33 -0
- package/skills/jhste-code-quality/references/code-quality.md +45 -0
- package/skills/jhste-crawler-automation/SKILL.md +23 -0
- package/skills/jhste-crawler-automation/references/crawler-automation.md +11 -0
- package/skills/jhste-db-api-boundary/SKILL.md +28 -0
- package/skills/jhste-db-api-boundary/references/db-api-boundary.md +21 -0
- package/skills/jhste-engineering-judgment/SKILL.md +107 -0
- package/skills/jhste-engineering-judgment/references/structure-templates.md +41 -0
- package/skills/jhste-red-team-review/SKILL.md +101 -0
- package/skills/jhste-red-team-review/references/red-team-review.md +83 -0
- package/skills/prototype/LOGIC.md +79 -0
- package/skills/prototype/SKILL.md +38 -0
- package/skills/prototype/UI.md +112 -0
- package/skills/setup/SKILL.md +21 -0
- package/skills/setup/references/conflict-policy.md +11 -0
- package/skills/setup/references/setup-flow.md +18 -0
- package/skills/to-issues/SKILL.md +91 -0
- package/skills/to-prd/SKILL.md +82 -0
- package/skills/triage/AGENT-BRIEF.md +168 -0
- package/skills/triage/OUT-OF-SCOPE.md +101 -0
- package/skills/triage/SKILL.md +111 -0
- package/skills/write-a-skill/SKILL.md +125 -0
- package/vendor/matt-pocock/LICENSE +21 -0
- package/vendor/matt-pocock/NOTICE.md +10 -0
- package/vendor/matt-pocock/allowlist.json +16 -0
- package/vendor/matt-pocock/source-lock.json +119 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isSourceCodePath, lineAt, maskCommentsAndStrings, violation } from './utils.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SETTINGS = Object.freeze({
|
|
5
|
+
function_review_lines: 80,
|
|
6
|
+
mixed_responsibility_hints: 3,
|
|
7
|
+
module_export_family_hints: 4,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const RESPONSIBILITY_HINTS = [
|
|
11
|
+
{ label: 'input parsing', patterns: [/\b(JSON\.parse|parseArgs|process\.argv|request\.json\(|new URL\(|URLSearchParams|yaml|frontmatter)\b/i] },
|
|
12
|
+
{ label: 'validation', patterns: [/\b(validate|validator|safeParse|parseAsync|schema|assert|parseEnv|errors\.push)\b/i] },
|
|
13
|
+
{ label: 'filesystem IO', patterns: [/\b(fs\.|readFile|writeFile|mkdir|rmSync|cpSync|readdirSync)\b/i] },
|
|
14
|
+
{ label: 'process/git/network IO', patterns: [/\b(spawnSync|execFileSync|fetch\(|axios\.|git\(|git\s+-C)\b/i] },
|
|
15
|
+
{ label: 'persistence', patterns: [/\b(prisma|pool\.query|client\.query|db\.|INSERT\s+INTO|UPDATE\s+\w|DELETE\s+FROM|SELECT\s+.+\s+FROM)\b/i] },
|
|
16
|
+
{ label: 'rendering/reporting', patterns: [/\b(console\.|Response\.json|NextResponse\.json|res\.json|render|markdown|tableRows|print|logger\.)\b/i] },
|
|
17
|
+
{ label: 'prompting', patterns: [/\b(ask\(|readline|question\(|prompt)\b/i] },
|
|
18
|
+
{ label: 'data transformation', patterns: [/\btransform|serialize|deserialize\b/i] },
|
|
19
|
+
{ label: 'time/crypto policy', patterns: [/\b(Date\.now|new Date\(|crypto\.|randomUUID|createHash)\b/i] },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const EXPORT_NAME_FAMILIES = [
|
|
23
|
+
{ family: 'argument parsing', pattern: /^(parseArgs|arg|args|option|options|usage|mode|choice|normalize)$/i },
|
|
24
|
+
{ family: 'git/repo discovery', pattern: /^(git|repo|repository|branch|changed|staged)$/i },
|
|
25
|
+
{ family: 'filesystem', pattern: /^(file|files|dir|directory|path|read|write|copy|digest|atomic|ensure)$/i },
|
|
26
|
+
{ family: 'prompting', pattern: /^(ask|confirm|prompt|question|user)$/i },
|
|
27
|
+
{ family: 'time/template', pattern: /^(now|template|profile|bridge|block|time)$/i },
|
|
28
|
+
{ family: 'validation/schema', pattern: /^(validate|validator|schema|known|default|settings)$/i },
|
|
29
|
+
{ family: 'reporting', pattern: /^(print|render|report|summary|table)$/i },
|
|
30
|
+
{ family: 'install orchestration', pattern: /^(install|apply|preflight|hook|skill|manifest)$/i },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function settingsOrDefault(settings = {}) {
|
|
34
|
+
return { ...DEFAULT_SETTINGS, ...(settings || {}) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function matchingHints(text) {
|
|
38
|
+
return RESPONSIBILITY_HINTS
|
|
39
|
+
.filter((hint) => hint.patterns.some((pattern) => pattern.test(text)))
|
|
40
|
+
.map((hint) => hint.label);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function functionDeclarations(text) {
|
|
44
|
+
const pattern = /(?:^|\n)([ \t]*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{|[ \t]*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{)/gu;
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const match of text.matchAll(pattern)) {
|
|
47
|
+
const declaration = match[1];
|
|
48
|
+
const name = match[2] || match[3] || 'anonymous';
|
|
49
|
+
const start = (match.index || 0) + (match[0].startsWith('\n') ? 1 : 0);
|
|
50
|
+
const openBrace = text.indexOf('{', start + declaration.indexOf(declaration.trim()));
|
|
51
|
+
if (openBrace === -1) continue;
|
|
52
|
+
const end = matchingBraceIndex(text, openBrace);
|
|
53
|
+
if (end === -1) continue;
|
|
54
|
+
out.push({ name, start, end, body: text.slice(start, end + 1) });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function matchingBraceIndex(text, openBrace) {
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let quote = '';
|
|
62
|
+
let escaped = false;
|
|
63
|
+
for (let index = openBrace; index < text.length; index += 1) {
|
|
64
|
+
const char = text[index];
|
|
65
|
+
if (quote) {
|
|
66
|
+
escaped = char === '\\' && !escaped;
|
|
67
|
+
if (char === quote && !escaped) quote = '';
|
|
68
|
+
if (char !== '\\') escaped = false;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
72
|
+
quote = char;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (char === '{') depth += 1;
|
|
76
|
+
if (char === '}') {
|
|
77
|
+
depth -= 1;
|
|
78
|
+
if (depth === 0) return index;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return -1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
function pythonFunctionDeclarations(text) {
|
|
86
|
+
const lines = text.split(/\r?\n/);
|
|
87
|
+
const out = [];
|
|
88
|
+
let offset = 0;
|
|
89
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
90
|
+
const line = lines[index];
|
|
91
|
+
const match = /^(\s*)def\s+([A-Za-z_][\w]*)\s*\([^)]*\)\s*:/.exec(line);
|
|
92
|
+
if (!match) {
|
|
93
|
+
offset += line.length + 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const indent = match[1].length;
|
|
97
|
+
const start = offset;
|
|
98
|
+
let endLine = index + 1;
|
|
99
|
+
for (; endLine < lines.length; endLine += 1) {
|
|
100
|
+
const candidate = lines[endLine];
|
|
101
|
+
if (!candidate.trim()) continue;
|
|
102
|
+
const candidateIndent = /^(\s*)/.exec(candidate)?.[1]?.length || 0;
|
|
103
|
+
if (candidateIndent <= indent) break;
|
|
104
|
+
}
|
|
105
|
+
const body = lines.slice(index, endLine).join('\n');
|
|
106
|
+
out.push({ name: match[2], start, end: start + body.length, body });
|
|
107
|
+
for (let consumed = index; consumed < endLine; consumed += 1) offset += lines[consumed].length + 1;
|
|
108
|
+
index = Math.max(index, endLine - 1);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function declarationsForPath(relPath, text) {
|
|
114
|
+
return relPath.endsWith('.py') ? pythonFunctionDeclarations(text) : functionDeclarations(text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function exportedCallableNames(text) {
|
|
118
|
+
const names = [];
|
|
119
|
+
for (const match of text.matchAll(/(?:^|\n)\s*export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/gu)) names.push(match[1]);
|
|
120
|
+
for (const match of text.matchAll(/(?:^|\n)\s*export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\(/gu)) names.push(match[1]);
|
|
121
|
+
return [...new Set(names.filter(Boolean))];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function nameTokens(name) {
|
|
125
|
+
return String(name)
|
|
126
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
127
|
+
.split(/[^A-Za-z0-9]+/)
|
|
128
|
+
.map((token) => token.toLowerCase())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function exportFamilies(names) {
|
|
133
|
+
const families = new Set();
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
const tokens = new Set(nameTokens(name));
|
|
136
|
+
for (const candidate of EXPORT_NAME_FAMILIES) {
|
|
137
|
+
if (candidate.pattern.test(name) || [...tokens].some((token) => candidate.pattern.test(token))) families.add(candidate.family);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return [...families];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function onlyReexports(text) {
|
|
144
|
+
const meaningful = text
|
|
145
|
+
.split(/\r?\n/)
|
|
146
|
+
.map((line) => line.trim())
|
|
147
|
+
.filter((line) => line && !line.startsWith('//'));
|
|
148
|
+
return meaningful.length > 0 && meaningful.every((line) => /^export\s+.*\s+from\s+['"][^'"]+['"];?$/.test(line));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function scanSingleResponsibility(relPath, text, rawSettings = {}) {
|
|
152
|
+
if (!isSourceCodePath(relPath)) return [];
|
|
153
|
+
if (/(^|\/)scripts\/smoke\/|fixtures-test\.mjs$/.test(relPath)) return [];
|
|
154
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
155
|
+
if (!['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py'].includes(ext)) return [];
|
|
156
|
+
const settings = settingsOrDefault(rawSettings);
|
|
157
|
+
const out = [];
|
|
158
|
+
const scanText = maskCommentsAndStrings(text);
|
|
159
|
+
|
|
160
|
+
for (const fn of declarationsForPath(relPath, scanText)) {
|
|
161
|
+
const lineCount = fn.body.split(/\r?\n/).length;
|
|
162
|
+
const hints = matchingHints(fn.body);
|
|
163
|
+
const scannerLikeFunction = /^scan[A-Z_]/.test(fn.name) || fn.name === 'scanMixedResponsibilities';
|
|
164
|
+
const optionNormalizer = /^normalize/.test(fn.name) && hints.every((hint) => ['input parsing', 'validation', 'filesystem IO'].includes(hint));
|
|
165
|
+
if (lineCount >= settings.function_review_lines) {
|
|
166
|
+
out.push(violation({
|
|
167
|
+
ruleId: 'srp.function.length',
|
|
168
|
+
severity: 'warning',
|
|
169
|
+
relPath,
|
|
170
|
+
line: lineAt(text, fn.start),
|
|
171
|
+
symbol: fn.name,
|
|
172
|
+
message: `${fn.name} is ${lineCount} lines; name its one responsibility and consider whether a real seam would improve locality.`,
|
|
173
|
+
confidence: 'medium',
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
if ((fn.name === 'main' && lineCount < settings.function_review_lines) || scannerLikeFunction || optionNormalizer) continue;
|
|
177
|
+
if (hints.length >= settings.mixed_responsibility_hints) {
|
|
178
|
+
out.push(violation({
|
|
179
|
+
ruleId: 'srp.function.mixed_responsibility',
|
|
180
|
+
severity: 'warning',
|
|
181
|
+
relPath,
|
|
182
|
+
line: lineAt(text, fn.start),
|
|
183
|
+
symbol: fn.name,
|
|
184
|
+
message: `${fn.name} appears to mix ${hints.slice(0, 5).join(', ')}; review whether one responsibility can move behind a named seam without creating pass-through wrappers.`,
|
|
185
|
+
confidence: 'low',
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!onlyReexports(text)) {
|
|
191
|
+
const families = exportFamilies(exportedCallableNames(scanText));
|
|
192
|
+
if (families.length >= settings.module_export_family_hints) {
|
|
193
|
+
out.push(violation({
|
|
194
|
+
ruleId: 'srp.module.mixed_exports',
|
|
195
|
+
severity: 'warning',
|
|
196
|
+
relPath,
|
|
197
|
+
symbol: 'module-exports',
|
|
198
|
+
message: `Module exports span ${families.slice(0, 6).join(', ')}; review whether this is a cohesive deep module or a shared utility bucket.`,
|
|
199
|
+
confidence: 'low',
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
countMatches,
|
|
3
|
+
hasUseClientDirective,
|
|
4
|
+
lineAt,
|
|
5
|
+
violation,
|
|
6
|
+
} from './utils.mjs';
|
|
7
|
+
|
|
8
|
+
export function scanClientServerBoundary(relPath, text) {
|
|
9
|
+
if (!hasUseClientDirective(text)) return [];
|
|
10
|
+
const out = [];
|
|
11
|
+
const pattern = /^\s*import\s+(?!type\b)[^;\n]*\sfrom\s+['"]([^'"]+)['"]/gmu;
|
|
12
|
+
for (const match of text.matchAll(pattern)) {
|
|
13
|
+
const source = match[1] || '';
|
|
14
|
+
if (/^(fs|path|crypto|child_process|server-only|next\/headers|next\/cookies|next\/server)$/.test(source)
|
|
15
|
+
|| /(^|\/)(server|db|database|repositories?|prisma|postgres)(\/|$)/i.test(source)) {
|
|
16
|
+
out.push(violation({
|
|
17
|
+
ruleId: 'boundary.import.server_in_client',
|
|
18
|
+
severity: 'error',
|
|
19
|
+
relPath,
|
|
20
|
+
line: lineAt(text, match.index || 0),
|
|
21
|
+
symbol: `import:${source}`,
|
|
22
|
+
message: `Client file imports server/runtime module '${source}'. Move loading to a server boundary or pass shaped data into the client module.`,
|
|
23
|
+
confidence: 'high',
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function scanStateSafety(relPath, text) {
|
|
31
|
+
const out = [];
|
|
32
|
+
if (!/\.(tsx?|jsx?)$/u.test(relPath)) return out;
|
|
33
|
+
for (const match of text.matchAll(/\b[A-Za-z_$][\w$]*!\s*(?:\.|\[|\()/gu)) {
|
|
34
|
+
out.push(violation({
|
|
35
|
+
ruleId: 'state.non_null_assertion',
|
|
36
|
+
severity: 'warning',
|
|
37
|
+
relPath,
|
|
38
|
+
line: lineAt(text, match.index || 0),
|
|
39
|
+
symbol: match[0].trim(),
|
|
40
|
+
message: 'Non-null assertion hides null or empty-state risk; prefer an explicit guard or fallback on the affected path.',
|
|
41
|
+
confidence: 'medium',
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
if ((hasUseClientDirective(text) || /page\.(tsx|jsx)$/.test(relPath))
|
|
45
|
+
&& /\b(useQuery|useSuspenseQuery|fetch\s*\(|axios\.)\b/.test(text)
|
|
46
|
+
&& !/\b(isLoading|loading|isError|error|notFound|empty|Empty|skeleton|placeholder)\b/.test(text)) {
|
|
47
|
+
out.push(violation({
|
|
48
|
+
ruleId: 'state.async_ui_missing_fallback',
|
|
49
|
+
severity: 'warning',
|
|
50
|
+
relPath,
|
|
51
|
+
symbol: 'async-ui-state',
|
|
52
|
+
message: 'Async UI path has data-loading hints but no obvious loading, empty, or error fallback; review state handling before ship.',
|
|
53
|
+
confidence: 'low',
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function scanRuntimeEnvSafety(relPath, text) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const lines = text.split(/\r?\n/);
|
|
62
|
+
lines.forEach((line, index) => {
|
|
63
|
+
if (/\bprocess\.env\.(?!NODE_ENV\b|JHSTE_HOOK_ACTIVE\b)[A-Z0-9_]+\b/.test(line) && !/\?\?|\|\||default|safeParse|parseEnv|assertEnv|requiredEnv|validate|schema/i.test(line)) {
|
|
64
|
+
out.push(violation({
|
|
65
|
+
ruleId: 'runtime.env_direct_access',
|
|
66
|
+
severity: 'warning',
|
|
67
|
+
relPath,
|
|
68
|
+
line: index + 1,
|
|
69
|
+
symbol: line.trim(),
|
|
70
|
+
message: 'Env var is read directly without an obvious validation or fallback path; review build/runtime setup safety.',
|
|
71
|
+
confidence: 'medium',
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
if (/\bimport\.meta\.env\.(?!MODE\b|DEV\b|PROD\b|SSR\b)[A-Z0-9_]+\b/.test(line) && !/\?\?|\|\||default|safeParse|validate|schema/i.test(line)) {
|
|
75
|
+
out.push(violation({
|
|
76
|
+
ruleId: 'runtime.import_meta_env_direct_access',
|
|
77
|
+
severity: 'warning',
|
|
78
|
+
relPath,
|
|
79
|
+
line: index + 1,
|
|
80
|
+
symbol: line.trim(),
|
|
81
|
+
message: 'Client env var is read directly without an obvious fallback or validation; review runtime safety before ship.',
|
|
82
|
+
confidence: 'medium',
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
if (/\bos\.getenv\(['"][A-Z0-9_]+['"]\)/.test(line) && !/\bor\b|\bif\b|default|validate|schema/i.test(line)) {
|
|
86
|
+
out.push(violation({
|
|
87
|
+
ruleId: 'runtime.getenv_direct_access',
|
|
88
|
+
severity: 'warning',
|
|
89
|
+
relPath,
|
|
90
|
+
line: index + 1,
|
|
91
|
+
symbol: line.trim(),
|
|
92
|
+
message: 'Python env lookup has no obvious fallback or validation; review startup/runtime safety.',
|
|
93
|
+
confidence: 'medium',
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function scanPerformanceDuplicateFetch(relPath, text) {
|
|
101
|
+
const out = [];
|
|
102
|
+
if (!/\.(tsx?|jsx?)$/u.test(relPath)) return out;
|
|
103
|
+
const fetchCount = countMatches(text, /\b(fetch\s*\(|axios\.|useQuery\s*\(|useSuspenseQuery\s*\()/g);
|
|
104
|
+
if (fetchCount >= 2) {
|
|
105
|
+
out.push(violation({
|
|
106
|
+
ruleId: 'performance.multiple_fetch_sources',
|
|
107
|
+
severity: 'warning',
|
|
108
|
+
relPath,
|
|
109
|
+
symbol: `fetch-count:${fetchCount}`,
|
|
110
|
+
message: 'File appears to trigger multiple fetch paths; review whether duplicate requests or split caches are avoidable.',
|
|
111
|
+
confidence: 'low',
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
if (hasUseClientDirective(text) && /useEffect\s*\([\s\S]{0,500}\b(fetch\s*\(|axios\.)/su.test(text)) {
|
|
115
|
+
out.push(violation({
|
|
116
|
+
ruleId: 'performance.fetch_in_effect',
|
|
117
|
+
severity: 'warning',
|
|
118
|
+
relPath,
|
|
119
|
+
symbol: 'fetch-in-effect',
|
|
120
|
+
message: 'Client module fetches inside useEffect; review whether the request can move to a cached loader or shared data hook.',
|
|
121
|
+
confidence: 'low',
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function scanSideEffectBoundary(relPath, text) {
|
|
128
|
+
if (!/\.(tsx?|jsx?|mjs|cjs|py)$/u.test(relPath)) return [];
|
|
129
|
+
if (/function\s+(format|helper|build|make|map)\w*\s*\([^)]*\)\s*{[\s\S]{0,1200}\b(fetch|writeFile|readFile|exec|spawn|setTimeout)\b/.test(text)) {
|
|
130
|
+
return [violation({
|
|
131
|
+
ruleId: 'side_effect.hidden_in_helper',
|
|
132
|
+
severity: 'warning',
|
|
133
|
+
relPath,
|
|
134
|
+
symbol: 'hidden-side-effect',
|
|
135
|
+
message: 'Generic helper appears to perform a side effect; make the side-effect seam visible in name, directory, or dependency injection.',
|
|
136
|
+
confidence: 'low',
|
|
137
|
+
})];
|
|
138
|
+
}
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export function normalizePath(value) {
|
|
5
|
+
return value.replaceAll(path.sep, '/').replace(/^\.\//, '');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function lineAt(text, index) {
|
|
9
|
+
return text.slice(0, index).split(/\r?\n/).length;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function maskCommentsAndStrings(text) {
|
|
13
|
+
const input = String(text || '');
|
|
14
|
+
let output = '';
|
|
15
|
+
let state = 'code';
|
|
16
|
+
let quote = '';
|
|
17
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
18
|
+
const char = input[index];
|
|
19
|
+
const next = input[index + 1] || '';
|
|
20
|
+
if (state === 'line-comment') {
|
|
21
|
+
if (char === '\n') {
|
|
22
|
+
state = 'code';
|
|
23
|
+
output += '\n';
|
|
24
|
+
} else {
|
|
25
|
+
output += ' ';
|
|
26
|
+
}
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (state === 'block-comment') {
|
|
30
|
+
if (char === '*' && next === '/') {
|
|
31
|
+
output += ' ';
|
|
32
|
+
index += 1;
|
|
33
|
+
state = 'code';
|
|
34
|
+
} else {
|
|
35
|
+
output += char === '\n' ? '\n' : ' ';
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (state === 'string') {
|
|
40
|
+
if (char === '\\') {
|
|
41
|
+
output += ' ';
|
|
42
|
+
if (next) {
|
|
43
|
+
output += next === '\n' ? '\n' : ' ';
|
|
44
|
+
index += 1;
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
output += char === '\n' ? '\n' : ' ';
|
|
49
|
+
if (char === quote) {
|
|
50
|
+
state = 'code';
|
|
51
|
+
quote = '';
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (char === '/' && next === '/') {
|
|
56
|
+
output += ' ';
|
|
57
|
+
index += 1;
|
|
58
|
+
state = 'line-comment';
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (char === '/' && next === '*') {
|
|
62
|
+
output += ' ';
|
|
63
|
+
index += 1;
|
|
64
|
+
state = 'block-comment';
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
68
|
+
output += ' ';
|
|
69
|
+
quote = char;
|
|
70
|
+
state = 'string';
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
output += char;
|
|
74
|
+
}
|
|
75
|
+
return output;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function localWindow(text, index, before = 320, after = 520) {
|
|
79
|
+
const start = Math.max(0, Number(index || 0) - before);
|
|
80
|
+
const end = Math.min(String(text || '').length, Number(index || 0) + after);
|
|
81
|
+
return String(text || '').slice(start, end);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function hasUseClientDirective(text) {
|
|
85
|
+
return /^\s*(?:"use client"|'use client')\s*;?/u.test(text);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isRouteLikePath(relPath) {
|
|
89
|
+
return /(^|\/)(api|routes?|controllers?|pages\/api)\//i.test(relPath) || /route\.(ts|js)$/.test(relPath);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isScriptPipelinePath(relPath) {
|
|
93
|
+
return /(^|\/)scripts\/(data|ops|import|imports|backfill|repair|migrate|migration)\//.test(relPath);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isSourceCodePath(relPath) {
|
|
97
|
+
return ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py'].includes(path.extname(relPath).toLowerCase());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isCrawlerProducerPath(relPath) {
|
|
101
|
+
return /(^|\/)(crawler|crawlers|scraper|scrapers|automation|workers?|schedulers?)\//i.test(relPath)
|
|
102
|
+
|| /crawler|scraper|automation|producer/i.test(path.basename(relPath));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function hasPersistenceRead(text) {
|
|
106
|
+
return /\b(prisma\.\w+\.(find(?:Unique|First|Many)?|aggregate|count)|pool\.query|client\.query|db\.|database\.)\b/i.test(text)
|
|
107
|
+
|| /\bSELECT\b[\s\S]{0,120}\bFROM\b/i.test(text);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function hasPersistenceWrite(text) {
|
|
111
|
+
return /\b(prisma\.\w+\.(create|update|delete|upsert)|pool\.query|client\.query|db\.)\b/i.test(text)
|
|
112
|
+
|| /\b(INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b/i.test(text);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function hasPersistenceAccess(text) {
|
|
116
|
+
return hasPersistenceRead(text) || hasPersistenceWrite(text);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function hasReadHandler(text) {
|
|
120
|
+
return /\b(export\s+async\s+function\s+GET|router\.get|app\.get)\b/i.test(text);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function hasMutationHandler(text) {
|
|
124
|
+
return /\b(export\s+async\s+function\s+(POST|PUT|PATCH|DELETE)|router\.(post|put|patch|delete)|app\.(post|put|patch|delete))\b/i.test(text);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function hasAuthContext(text) {
|
|
128
|
+
return /\b(auth\s*\(|session|currentUser|getUser|permission|requireUser|requireAuth)\b/i.test(text);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function hasScopeHint(text) {
|
|
132
|
+
return /\b(userId|user\.id|accountId|orgId|tenantId|ownerId|workspaceId|teamId|projectId|where\s*:|filter\s*:)\b/i.test(text);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function countMatches(text, pattern) {
|
|
136
|
+
return [...text.matchAll(pattern)].length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function occurrenceKeyFor(ruleId, relPath, symbol, line) {
|
|
140
|
+
const stable = `${ruleId}|${normalizePath(relPath)}|${line || 1}|${symbol || ''}`;
|
|
141
|
+
return crypto.createHash('sha1').update(stable).digest('hex').slice(0, 16);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function fingerprintFor(ruleId, relPath, occurrenceKey) {
|
|
145
|
+
const stable = `${ruleId}|${normalizePath(relPath)}|${occurrenceKey}`;
|
|
146
|
+
return crypto.createHash('sha1').update(stable).digest('hex');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function violation({ ruleId, severity, relPath, line = 1, symbol = '', message, source = 'builtin', confidence = 'medium', relatedKey = '' }) {
|
|
150
|
+
const isHeuristic = confidence !== 'high';
|
|
151
|
+
const occurrenceKey = occurrenceKeyFor(ruleId, relPath, symbol, line);
|
|
152
|
+
return {
|
|
153
|
+
rule_id: ruleId,
|
|
154
|
+
severity,
|
|
155
|
+
path: normalizePath(relPath),
|
|
156
|
+
line,
|
|
157
|
+
symbol,
|
|
158
|
+
message,
|
|
159
|
+
occurrence_key: occurrenceKey,
|
|
160
|
+
fingerprint: fingerprintFor(ruleId, relPath, occurrenceKey),
|
|
161
|
+
source,
|
|
162
|
+
confidence,
|
|
163
|
+
category: isHeuristic ? 'heuristic_candidate' : 'proof_like',
|
|
164
|
+
why_not_proof: isHeuristic ? 'Pattern-based scanner result; confirm against code context before treating as proof.' : null,
|
|
165
|
+
related_key: relatedKey || null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const TEXT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py', '.yml', '.yaml']);
|
|
6
|
+
const EXCLUDED_DIRS = new Set(['.git', 'node_modules', 'vendor', 'dist', 'build', '.next', 'out', 'coverage', '.turbo', '.cache', '__pycache__']);
|
|
7
|
+
const EXCLUDED_FILE_NAMES = new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lockb', 'bun.lock', 'poetry.lock', 'Pipfile.lock']);
|
|
8
|
+
const SECRET_FILE_RE = /(^|\/)(\.env(\..*)?|.*\.(pem|key|p12|pfx|crt)|id_rsa|id_ed25519)$/i;
|
|
9
|
+
|
|
10
|
+
function git(repoRoot, args, options = {}) {
|
|
11
|
+
return execFileSync('git', ['-C', repoRoot, ...args], {
|
|
12
|
+
encoding: 'utf8',
|
|
13
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
14
|
+
...options,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizePath(value) {
|
|
19
|
+
return value.replaceAll(path.sep, '/').replace(/^\.\//, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isScannablePath(relPath) {
|
|
23
|
+
const normalized = normalizePath(relPath);
|
|
24
|
+
if (!normalized || normalized.startsWith('..')) return false;
|
|
25
|
+
if (EXCLUDED_FILE_NAMES.has(path.basename(normalized)) || SECRET_FILE_RE.test(normalized)) return false;
|
|
26
|
+
if (normalized.split('/').some((part) => EXCLUDED_DIRS.has(part))) return false;
|
|
27
|
+
return TEXT_EXTENSIONS.has(path.extname(normalized).toLowerCase());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function listAllFiles(repoRoot) {
|
|
31
|
+
const out = [];
|
|
32
|
+
function walk(dir) {
|
|
33
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
34
|
+
const full = path.join(dir, entry.name);
|
|
35
|
+
const rel = normalizePath(path.relative(repoRoot, full));
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
if (!EXCLUDED_DIRS.has(entry.name)) walk(full);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (entry.isFile() && isScannablePath(rel)) out.push(rel);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
walk(repoRoot);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function listGitBackedAllFiles(repoRoot) {
|
|
48
|
+
return readGitPathList(repoRoot, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readNulFile(filePath) {
|
|
52
|
+
const raw = fs.readFileSync(filePath);
|
|
53
|
+
return raw.toString('utf8').split('\0').map((item) => item.trim()).filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readGitPathList(repoRoot, args) {
|
|
57
|
+
const raw = execFileSync('git', ['-C', repoRoot, ...args], { encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
58
|
+
return raw.toString('utf8').split('\0').map((item) => item.trim()).filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveBaseRef(repoRoot, explicitBase, headRef) {
|
|
62
|
+
if (explicitBase) return explicitBase;
|
|
63
|
+
for (const candidate of ['origin/main', 'main', 'origin/master', 'master']) {
|
|
64
|
+
try {
|
|
65
|
+
git(repoRoot, ['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`]);
|
|
66
|
+
return git(repoRoot, ['merge-base', candidate, headRef]).trim();
|
|
67
|
+
} catch {
|
|
68
|
+
// Try the next common base candidate.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return git(repoRoot, ['rev-parse', `${headRef}^`]).trim();
|
|
73
|
+
} catch {
|
|
74
|
+
return '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function repoRelativePath(repoRoot, rawPath) {
|
|
79
|
+
const raw = String(rawPath || '').trim();
|
|
80
|
+
if (!raw) return '';
|
|
81
|
+
const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(repoRoot, raw);
|
|
82
|
+
const relative = normalizePath(path.relative(repoRoot, resolved));
|
|
83
|
+
if (!relative || relative === '.') return '';
|
|
84
|
+
if (relative.startsWith('../') || relative === '..' || path.isAbsolute(relative)) {
|
|
85
|
+
throw new Error(`Path escapes repo root: ${raw}`);
|
|
86
|
+
}
|
|
87
|
+
return relative;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function uniqueExistingScannableWithCount(repoRoot, relPaths) {
|
|
91
|
+
const normalized = [];
|
|
92
|
+
for (const relPath of relPaths) {
|
|
93
|
+
if (!relPath) continue;
|
|
94
|
+
normalized.push(repoRelativePath(repoRoot, relPath));
|
|
95
|
+
}
|
|
96
|
+
const unique = [...new Set(normalized)].sort();
|
|
97
|
+
return {
|
|
98
|
+
considered: unique.length,
|
|
99
|
+
files: unique.filter(isScannablePath).filter((rel) => fs.existsSync(path.join(repoRoot, rel))),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scopePayload(repoRoot, scope, relPaths, gitMeta = {}) {
|
|
104
|
+
const { files, considered } = uniqueExistingScannableWithCount(repoRoot, relPaths);
|
|
105
|
+
return { scope, files, files_considered: considered, git: gitMeta };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveScopeFiles(repoRoot, args, callbacks = {}) {
|
|
109
|
+
const failConfig = callbacks.failConfig || ((message) => { throw new Error(message); });
|
|
110
|
+
const failGuard = callbacks.failGuard || ((message) => { throw new Error(message); });
|
|
111
|
+
const scope = String(args.scope || 'changed');
|
|
112
|
+
if (!['changed', 'staged', 'all', 'files-from'].includes(scope)) {
|
|
113
|
+
failConfig(`Unsupported --scope ${scope}. Use changed, staged, all, or files-from.`);
|
|
114
|
+
}
|
|
115
|
+
const gitMeta = {
|
|
116
|
+
root: repoRoot,
|
|
117
|
+
head: '',
|
|
118
|
+
base: '',
|
|
119
|
+
file_source: null,
|
|
120
|
+
fallback_reason: null,
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
gitMeta.head = git(repoRoot, ['rev-parse', '--short', 'HEAD']).trim();
|
|
124
|
+
} catch {
|
|
125
|
+
gitMeta.head = 'unavailable';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (scope === 'all') {
|
|
129
|
+
try {
|
|
130
|
+
git(repoRoot, ['rev-parse', '--is-inside-work-tree']);
|
|
131
|
+
const rawFiles = listGitBackedAllFiles(repoRoot);
|
|
132
|
+
gitMeta.file_source = 'git-ls-files';
|
|
133
|
+
return scopePayload(repoRoot, scope, rawFiles, gitMeta);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
gitMeta.file_source = 'filesystem-fallback';
|
|
136
|
+
gitMeta.fallback_reason = error instanceof Error ? error.message : String(error);
|
|
137
|
+
const files = listAllFiles(repoRoot).sort();
|
|
138
|
+
return { scope, files, files_considered: files.length, git: gitMeta };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (scope === 'files-from') {
|
|
143
|
+
if (!args['files-from']) failConfig('--scope files-from requires --files-from <nul-delimited-file>.');
|
|
144
|
+
let rawPaths;
|
|
145
|
+
try {
|
|
146
|
+
rawPaths = readNulFile(path.resolve(String(args['files-from'])));
|
|
147
|
+
} catch (error) {
|
|
148
|
+
failGuard('Failed to read --files-from input.', [error instanceof Error ? error.message : String(error)]);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
return scopePayload(repoRoot, scope, rawPaths, gitMeta);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
failGuard('Invalid path in --files-from input.', [error instanceof Error ? error.message : String(error)]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (scope === 'staged') {
|
|
158
|
+
try {
|
|
159
|
+
return scopePayload(repoRoot, scope, readGitPathList(repoRoot, ['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMRTUX', '--']), gitMeta);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
failGuard('Failed to resolve staged files.', [error instanceof Error ? error.message : String(error)]);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const headRef = String(args.head || 'HEAD');
|
|
167
|
+
const baseRef = resolveBaseRef(repoRoot, args.base ? String(args.base) : '', headRef);
|
|
168
|
+
gitMeta.head = git(repoRoot, ['rev-parse', '--short', headRef]).trim();
|
|
169
|
+
gitMeta.base = baseRef;
|
|
170
|
+
const files = [
|
|
171
|
+
...readGitPathList(repoRoot, ['diff', '--name-only', '-z', '--diff-filter=ACMRTUX', baseRef, headRef, '--']),
|
|
172
|
+
...readGitPathList(repoRoot, ['diff', '--name-only', '-z', '--diff-filter=ACMRTUX', '--']),
|
|
173
|
+
...readGitPathList(repoRoot, ['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMRTUX', '--']),
|
|
174
|
+
...readGitPathList(repoRoot, ['ls-files', '--others', '--exclude-standard', '-z']),
|
|
175
|
+
];
|
|
176
|
+
return scopePayload(repoRoot, scope, files, gitMeta);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
failGuard('Failed to resolve changed files.', [error instanceof Error ? error.message : String(error)]);
|
|
179
|
+
}
|
|
180
|
+
return { scope, files: [], files_considered: 0, git: gitMeta };
|
|
181
|
+
}
|