sentinelayer-cli 0.25.0 → 0.25.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,279 @@
1
+ import { frontendAnalyze } from "./frontend-analyze.js";
2
+
3
+ const SEVERITIES = new Set(["P0", "P1", "P2", "P3"]);
4
+
5
+ function toPosix(value) {
6
+ return String(value || "").replace(/\\/g, "/");
7
+ }
8
+
9
+ function normalizeSeverity(value, fallback = "P2") {
10
+ const normalized = String(value || "").trim().toUpperCase();
11
+ return SEVERITIES.has(normalized) ? normalized : fallback;
12
+ }
13
+
14
+ function normalizeFiles(files) {
15
+ if (!Array.isArray(files)) return [];
16
+ return files.map((file) => toPosix(file).trim()).filter(Boolean);
17
+ }
18
+
19
+ function isInScope(file, scopeFiles) {
20
+ const normalized = toPosix(file);
21
+ if (!normalized || scopeFiles.length === 0) return true;
22
+ return scopeFiles.some((candidate) =>
23
+ candidate === normalized ||
24
+ candidate.endsWith(`/${normalized}`) ||
25
+ normalized.endsWith(`/${candidate}`)
26
+ );
27
+ }
28
+
29
+ function createFinding({
30
+ kind,
31
+ severity,
32
+ file = "",
33
+ line = 0,
34
+ evidence = "",
35
+ rootCause = "",
36
+ recommendedFix = "",
37
+ confidence = 0.7,
38
+ } = {}) {
39
+ return {
40
+ persona: "frontend",
41
+ tool: "frontend-analyze",
42
+ kind: String(kind || "frontend.finding"),
43
+ severity: normalizeSeverity(severity),
44
+ file: toPosix(file),
45
+ line: Number.isFinite(Number(line)) ? Math.max(0, Math.floor(Number(line))) : 0,
46
+ evidence: String(evidence || "").trim().slice(0, 400),
47
+ rootCause: String(rootCause || "").trim(),
48
+ recommendedFix: String(recommendedFix || "").trim(),
49
+ confidence: Math.max(0, Math.min(1, Number(confidence) || 0)),
50
+ };
51
+ }
52
+
53
+ function dedupeFindings(findings) {
54
+ const seen = new Set();
55
+ const deduped = [];
56
+ for (const finding of findings) {
57
+ const key = [
58
+ finding.kind,
59
+ finding.file,
60
+ finding.line,
61
+ finding.evidence,
62
+ ].join("|");
63
+ if (seen.has(key)) continue;
64
+ seen.add(key);
65
+ deduped.push(finding);
66
+ }
67
+ return deduped;
68
+ }
69
+
70
+ function analyze(operation, rootPath) {
71
+ try {
72
+ return frontendAnalyze({ operation, path: rootPath });
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Deterministic Investor-DD adapter for Jules/frontend.
80
+ *
81
+ * `frontendAnalyze` is intentionally repo-scoped, so the Investor-DD runner
82
+ * invokes this as a single `scope: "repo"` tool per frontend persona run.
83
+ */
84
+ export async function runFrontendInvestorDdAnalyze({ rootPath, files = [] } = {}) {
85
+ const scopeFiles = normalizeFiles(files);
86
+ const findings = [];
87
+
88
+ const securitySinks = analyze("find_security_sinks", rootPath);
89
+ for (const sink of securitySinks?.sinks || []) {
90
+ if (!isInScope(sink.file, scopeFiles)) continue;
91
+ findings.push(createFinding({
92
+ kind: `frontend.security.${String(sink.type || "sink").replace(/\W+/g, "_")}`,
93
+ severity: sink.severity || "P2",
94
+ file: sink.file,
95
+ line: sink.line || 0,
96
+ evidence: `${sink.type || "frontend security sink"} detected`,
97
+ rootCause: "Frontend markup or script execution sink can create XSS or client-side code execution risk.",
98
+ recommendedFix: "Remove the sink or sanitize/escape untrusted content with a reviewed allowlist sanitizer.",
99
+ confidence: sink.severity === "P0" ? 0.85 : 0.75,
100
+ }));
101
+ }
102
+
103
+ const envExposure = analyze("find_env_exposure", rootPath);
104
+ for (const exposure of envExposure?.findings || []) {
105
+ if (!isInScope(exposure.file, scopeFiles)) continue;
106
+ findings.push(createFinding({
107
+ kind: "frontend.env_exposure",
108
+ severity: exposure.severity || (exposure.sensitive ? "P1" : "P3"),
109
+ file: exposure.file,
110
+ line: exposure.line || 0,
111
+ evidence: `${exposure.variable || "public env variable"} referenced in frontend source`,
112
+ rootCause: "Public frontend environment prefixes are shipped to the browser; sensitive names can leak credentials or encourage unsafe configuration.",
113
+ recommendedFix: "Keep secrets server-side and expose only explicitly public, non-sensitive configuration to client bundles.",
114
+ confidence: exposure.sensitive ? 0.8 : 0.45,
115
+ }));
116
+ }
117
+
118
+ const missingCleanup = analyze("find_missing_cleanup", rootPath);
119
+ for (const cleanup of missingCleanup?.findings || []) {
120
+ if (!isInScope(cleanup.file, scopeFiles)) continue;
121
+ findings.push(createFinding({
122
+ kind: "frontend.effect_missing_cleanup",
123
+ severity: cleanup.severity || "P2",
124
+ file: cleanup.file,
125
+ evidence: cleanup.pattern || "Effect with timer/subscription has no cleanup return",
126
+ rootCause: "Effects that subscribe or schedule timers without cleanup leak work across remounts and route changes.",
127
+ recommendedFix: "Return a cleanup function that unsubscribes, removes listeners, or clears timers.",
128
+ confidence: 0.7,
129
+ }));
130
+ }
131
+
132
+ const staleClosures = analyze("find_stale_closures", rootPath);
133
+ for (const closure of staleClosures?.findings || []) {
134
+ if (!isInScope(closure.file, scopeFiles)) continue;
135
+ findings.push(createFinding({
136
+ kind: "frontend.stale_closure",
137
+ severity: closure.severity || "P2",
138
+ file: closure.file,
139
+ line: closure.line || 0,
140
+ evidence: closure.pattern || "useEffect with empty dependency array may capture stale values",
141
+ rootCause: "Effects with empty dependencies can retain initial state/props while the UI continues to change.",
142
+ recommendedFix: "Declare dependencies, use stable refs intentionally, or move logic out of the effect.",
143
+ confidence: 0.55,
144
+ }));
145
+ }
146
+
147
+ const stateHooks = analyze("count_state_hooks", rootPath);
148
+ for (const component of stateHooks?.refactorCandidates || []) {
149
+ if (!isInScope(component.file, scopeFiles)) continue;
150
+ findings.push(createFinding({
151
+ kind: "frontend.state_god_component",
152
+ severity: component.risk === "god_component" ? "P2" : "P3",
153
+ file: component.file,
154
+ evidence: `${component.useStateCount} useState calls (${component.risk})`,
155
+ rootCause: "High local state count usually indicates a component with too many responsibilities and fragile render behavior.",
156
+ recommendedFix: "Split the component or consolidate related state transitions with a reducer/domain hook.",
157
+ confidence: component.risk === "god_component" ? 0.7 : 0.45,
158
+ }));
159
+ }
160
+
161
+ const accessibility = analyze("check_accessibility", rootPath);
162
+ for (const check of accessibility?.findings || []) {
163
+ const severity = normalizeSeverity(check.severity, "P3");
164
+ if (String(check.severity || "").toLowerCase() === "pass") continue;
165
+ if (Number(check.matchCount || 0) <= 0 && !String(check.check || "").includes("skip navigation")) continue;
166
+ findings.push(createFinding({
167
+ kind: "frontend.accessibility_check",
168
+ severity,
169
+ evidence: `${check.check || "accessibility heuristic"}; matches=${check.matchCount || 0}`,
170
+ rootCause: "Accessibility regressions block keyboard and assistive-technology users from completing core flows.",
171
+ recommendedFix: "Verify semantic labels, keyboard reachability, focus order, skip navigation, and automated axe coverage.",
172
+ confidence: 0.45,
173
+ }));
174
+ }
175
+
176
+ const mobile = analyze("check_mobile_responsive", rootPath);
177
+ for (const check of mobile?.findings || []) {
178
+ const rawSeverity = String(check.severity || "");
179
+ if (rawSeverity === "pass" || rawSeverity === "info") continue;
180
+ findings.push(createFinding({
181
+ kind: "frontend.mobile_responsive",
182
+ severity: check.severity || "P2",
183
+ evidence: `${check.check || "responsive heuristic"}; present=${check.present}; count=${check.count || 0}`,
184
+ rootCause: "Missing viewport or responsive styling evidence can make primary flows fail on mobile.",
185
+ recommendedFix: "Add viewport metadata and validate mobile/tablet breakpoints for the core user journey.",
186
+ confidence: 0.55,
187
+ }));
188
+ }
189
+
190
+ const errorBoundaries = analyze("check_error_boundaries", rootPath);
191
+ if (errorBoundaries && errorBoundaries.severity !== "pass") {
192
+ findings.push(createFinding({
193
+ kind: "frontend.error_boundary_gap",
194
+ severity: errorBoundaries.severity || "P2",
195
+ evidence: `errorBoundaryFiles=${errorBoundaries.errorBoundaryFiles || 0}; routeCount=${errorBoundaries.routeCount || 0}; coverage=${errorBoundaries.coverage || "n/a"}`,
196
+ rootCause: "Route-level runtime exceptions can blank the UI without a recoverable boundary.",
197
+ recommendedFix: "Add framework-native error boundaries for routed surfaces and test the failure state.",
198
+ confidence: 0.65,
199
+ }));
200
+ }
201
+
202
+ const images = analyze("check_image_optimization", rootPath);
203
+ if (images && images.severity !== "pass") {
204
+ findings.push(createFinding({
205
+ kind: "frontend.image_optimization",
206
+ severity: images.severity || "P2",
207
+ evidence: `rawImgTags=${images.rawImgTags || 0}; nextImageUsage=${images.nextImageUsage || 0}; missingDimensions=${images.missingDimensions || 0}`,
208
+ rootCause: "Unoptimized or dimensionless images can hurt LCP and cause layout shifts.",
209
+ recommendedFix: "Use framework image components or explicit dimensions/lazy loading for non-critical images.",
210
+ confidence: 0.55,
211
+ }));
212
+ } else if (images && Number(images.missingDimensions || 0) > 0) {
213
+ findings.push(createFinding({
214
+ kind: "frontend.image_dimensions_missing",
215
+ severity: "P2",
216
+ evidence: `missingDimensions=${images.missingDimensions}`,
217
+ rootCause: "Images without stable dimensions can cause cumulative layout shift.",
218
+ recommendedFix: "Set width/height or reserve aspect-ratio boxes for rendered images.",
219
+ confidence: 0.65,
220
+ }));
221
+ }
222
+
223
+ const fonts = analyze("check_font_loading", rootPath);
224
+ if (fonts && fonts.severity !== "pass") {
225
+ findings.push(createFinding({
226
+ kind: "frontend.font_loading",
227
+ severity: fonts.severity || "P2",
228
+ evidence: `fontDisplayUsage=${fonts.fontDisplayUsage || 0}; googleFontsUsage=${fonts.googleFontsUsage || 0}`,
229
+ rootCause: "Remote fonts without font-display controls can block text rendering or shift layout.",
230
+ recommendedFix: "Use font-display swap/optional, preload critical fonts, or self-host critical font assets.",
231
+ confidence: 0.55,
232
+ }));
233
+ }
234
+
235
+ const css = analyze("check_css_health", rootPath);
236
+ if (css && css.severity !== "pass") {
237
+ findings.push(createFinding({
238
+ kind: "frontend.css_health",
239
+ severity: css.severity || "P2",
240
+ evidence: `importantCount=${css.importantCount || 0}; tailwindConfigured=${Boolean(css.tailwindConfigured)}`,
241
+ rootCause: "Large numbers of !important declarations make UI state and responsive overrides brittle.",
242
+ recommendedFix: "Refactor repeated overrides into component variants or ordered utility/style layers.",
243
+ confidence: 0.45,
244
+ }));
245
+ }
246
+
247
+ const testCoverage = analyze("find_test_coverage", rootPath);
248
+ if (testCoverage && Number(testCoverage.componentCount || 0) > 0 && Number(testCoverage.untestedCount || 0) > 0) {
249
+ findings.push(createFinding({
250
+ kind: "frontend.test_coverage_gap",
251
+ severity: Number(testCoverage.testCount || 0) === 0 ? "P2" : "P3",
252
+ evidence: `components=${testCoverage.componentCount}; tests=${testCoverage.testCount}; untested=${testCoverage.untestedCount}; ratio=${testCoverage.coverageRatio || "n/a"}`,
253
+ rootCause: "Interactive components without nearby tests are likely to regress without being caught pre-merge.",
254
+ recommendedFix: "Add component/unit tests for primary states and at least one integration smoke for the core route.",
255
+ confidence: 0.55,
256
+ }));
257
+ }
258
+
259
+ return dedupeFindings(findings);
260
+ }
261
+
262
+ export const FRONTEND_TOOLS = Object.freeze({
263
+ "frontend-analyze": {
264
+ id: "frontend-analyze",
265
+ scope: "repo",
266
+ description:
267
+ "Run Jules Tanaka's deterministic frontend analyzer once for the routed frontend surface and convert UI/UX, accessibility, runtime, and web-vital heuristics into Investor-DD findings.",
268
+ schema: {
269
+ type: "object",
270
+ properties: {
271
+ rootPath: { type: "string" },
272
+ files: { type: "array", items: { type: "string" } },
273
+ },
274
+ },
275
+ handler: runFrontendInvestorDdAnalyze,
276
+ },
277
+ });
278
+
279
+ export const FRONTEND_TOOL_IDS = Object.freeze(Object.keys(FRONTEND_TOOLS));
@@ -1,3 +1,5 @@
1
+ import { isAuditSourceFile } from "./path-classification.js";
2
+
1
3
  function normalizeString(value) {
2
4
  return String(value || "").trim();
3
5
  }
@@ -21,6 +23,7 @@ function summarizeSeverity(findings = []) {
21
23
  function buildHotspots(ingest = {}) {
22
24
  const indexed = Array.isArray(ingest.indexedFiles?.files) ? ingest.indexedFiles.files : [];
23
25
  return indexed
26
+ .filter((file) => isAuditSourceFile(file))
24
27
  .filter((file) => Number(file.loc || 0) >= 300)
25
28
  .sort((left, right) => Number(right.loc || 0) - Number(left.loc || 0))
26
29
  .slice(0, 20)
@@ -50,7 +53,7 @@ function deriveArchitectureFindings({ findings = [], hotspots = [] } = {}) {
50
53
  continue;
51
54
  }
52
55
  derived.push({
53
- severity: hotspot.risk === "high" ? "P1" : "P2",
56
+ severity: "P2",
54
57
  file: hotspot.path,
55
58
  line: 1,
56
59
  message: `Large architectural hotspot detected (${hotspot.loc} LOC).`,
@@ -1,3 +1,5 @@
1
+ import { isAuditSourceFile } from "./path-classification.js";
2
+
1
3
  function normalizeString(value) {
2
4
  return String(value || "").trim();
3
5
  }
@@ -26,9 +28,7 @@ function buildDocumentationInventory(ingest = {}) {
26
28
  language: file.language,
27
29
  }));
28
30
  const docFiles = normalized.filter((file) => /(^|\/)(docs?|guides?|adr|readme)/i.test(file.path));
29
- const codeFiles = normalized.filter(
30
- (file) => file.loc > 0 && !/(^|\/)(docs?|guides?|adr|readme)/i.test(file.path)
31
- );
31
+ const codeFiles = normalized.filter((file) => file.loc > 0 && isAuditSourceFile(file));
32
32
  const docDensity = codeFiles.length > 0 ? docFiles.length / codeFiles.length : 0;
33
33
  const undocumentedHotspots = codeFiles
34
34
  .filter((file) => file.loc >= 320)
@@ -42,7 +42,7 @@ function buildDocumentationInventory(ingest = {}) {
42
42
  path: file.path,
43
43
  loc: file.loc,
44
44
  language: file.language,
45
- severity: file.loc >= 800 ? "P1" : "P2",
45
+ severity: "P2",
46
46
  }));
47
47
 
48
48
  return {
@@ -0,0 +1,120 @@
1
+ const SOURCE_EXTENSIONS = new Set([
2
+ ".c",
3
+ ".cc",
4
+ ".cpp",
5
+ ".cs",
6
+ ".css",
7
+ ".go",
8
+ ".graphql",
9
+ ".h",
10
+ ".hpp",
11
+ ".java",
12
+ ".js",
13
+ ".jsx",
14
+ ".kt",
15
+ ".mjs",
16
+ ".php",
17
+ ".py",
18
+ ".rb",
19
+ ".rs",
20
+ ".scss",
21
+ ".sql",
22
+ ".svelte",
23
+ ".swift",
24
+ ".tf",
25
+ ".ts",
26
+ ".tsx",
27
+ ".vue",
28
+ ]);
29
+
30
+ const SOURCE_LANGUAGES = new Set([
31
+ "c",
32
+ "c++",
33
+ "c#",
34
+ "css",
35
+ "dockerfile",
36
+ "go",
37
+ "graphql",
38
+ "java",
39
+ "javascript",
40
+ "jsx",
41
+ "kotlin",
42
+ "php",
43
+ "python",
44
+ "ruby",
45
+ "rust",
46
+ "scss",
47
+ "sql",
48
+ "svelte",
49
+ "swift",
50
+ "terraform",
51
+ "typescript",
52
+ "tsx",
53
+ "vue",
54
+ ]);
55
+
56
+ const LOCKFILE_NAMES = new Set([
57
+ "cargo.lock",
58
+ "composer.lock",
59
+ "go.sum",
60
+ "package-lock.json",
61
+ "pipfile.lock",
62
+ "pnpm-lock.yaml",
63
+ "poetry.lock",
64
+ "yarn.lock",
65
+ ]);
66
+
67
+ function normalizePath(value) {
68
+ return String(value || "").replace(/\\/g, "/").replace(/^\.\/+/, "").toLowerCase();
69
+ }
70
+
71
+ function basename(filePath) {
72
+ const normalized = normalizePath(filePath);
73
+ return normalized.slice(normalized.lastIndexOf("/") + 1);
74
+ }
75
+
76
+ function extension(filePath) {
77
+ const name = basename(filePath);
78
+ const dot = name.lastIndexOf(".");
79
+ return dot > 0 ? name.slice(dot) : "";
80
+ }
81
+
82
+ export function isAuditTestPath(filePath) {
83
+ const normalized = normalizePath(filePath);
84
+ return /(^|\/)(__tests__|tests?|specs?)(\/|$)|\.(test|spec)\.[^.\/]+$/i.test(normalized);
85
+ }
86
+
87
+ export function isAuditSupportPath(filePath) {
88
+ const normalized = normalizePath(filePath);
89
+ if (!normalized) {
90
+ return true;
91
+ }
92
+ if (LOCKFILE_NAMES.has(basename(normalized))) {
93
+ return true;
94
+ }
95
+ if (/(^|\/)(node_modules|vendor|fixtures?|__fixtures__|mocks?|__mocks__|snapshots?|__snapshots__)(\/|$)/i.test(normalized)) {
96
+ return true;
97
+ }
98
+ if (/(^|\/)(generated|__generated__|auto-generated|coverage|dist|build|out|\.next|\.nuxt|\.turbo|\.cache)(\/|$)/i.test(normalized)) {
99
+ return true;
100
+ }
101
+ if (/^(docs?|guides?|adr|tasks?|\.github|\.vscode|\.husky|public)(\/|$)/i.test(normalized)) {
102
+ return true;
103
+ }
104
+ if (/(\.min|\.bundle|generated)\.[^.\/]+$/i.test(normalized)) {
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ export function isAuditSourceFile(file = {}) {
111
+ const filePath = typeof file === "string" ? file : file.path;
112
+ const normalized = normalizePath(filePath);
113
+ if (!normalized || isAuditTestPath(normalized) || isAuditSupportPath(normalized)) {
114
+ return false;
115
+ }
116
+ if (SOURCE_EXTENSIONS.has(extension(normalized))) {
117
+ return true;
118
+ }
119
+ return SOURCE_LANGUAGES.has(String(file.language || "").trim().toLowerCase());
120
+ }
@@ -1,3 +1,5 @@
1
+ import { isAuditSourceFile } from "./path-classification.js";
2
+
1
3
  function normalizeString(value) {
2
4
  return String(value || "").trim();
3
5
  }
@@ -21,6 +23,7 @@ function summarizeSeverity(findings = []) {
21
23
  function buildRuntimeHotspots(ingest = {}) {
22
24
  const indexed = Array.isArray(ingest.indexedFiles?.files) ? ingest.indexedFiles.files : [];
23
25
  return indexed
26
+ .filter((file) => isAuditSourceFile(file))
24
27
  .filter((file) => Number(file.loc || 0) >= 280)
25
28
  .sort((left, right) => Number(right.loc || 0) - Number(left.loc || 0))
26
29
  .slice(0, 20)
@@ -28,7 +31,7 @@ function buildRuntimeHotspots(ingest = {}) {
28
31
  path: toPosixPath(file.path),
29
32
  loc: Number(file.loc || 0),
30
33
  language: file.language,
31
- severity: Number(file.loc || 0) >= 900 ? "P1" : "P2",
34
+ severity: "P2",
32
35
  }));
33
36
  }
34
37
 
@@ -1,3 +1,5 @@
1
+ import { isAuditSourceFile, isAuditTestPath } from "./path-classification.js";
2
+
1
3
  function normalizeString(value) {
2
4
  return String(value || "").trim();
3
5
  }
@@ -18,10 +20,6 @@ function summarizeSeverity(findings = []) {
18
20
  return summary;
19
21
  }
20
22
 
21
- function isTestPath(filePath) {
22
- return /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^.]+$/i.test(filePath);
23
- }
24
-
25
23
  function buildCoverageInventory(ingest = {}) {
26
24
  const indexedFiles = Array.isArray(ingest.indexedFiles?.files) ? ingest.indexedFiles.files : [];
27
25
  const normalized = indexedFiles.map((file) => ({
@@ -30,8 +28,8 @@ function buildCoverageInventory(ingest = {}) {
30
28
  loc: Number(file.loc || 0),
31
29
  }));
32
30
 
33
- const testFiles = normalized.filter((file) => isTestPath(file.path));
34
- const nonTestCodeFiles = normalized.filter((file) => !isTestPath(file.path) && file.loc > 0);
31
+ const testFiles = normalized.filter((file) => isAuditTestPath(file.path));
32
+ const nonTestCodeFiles = normalized.filter((file) => isAuditSourceFile(file) && file.loc > 0);
35
33
  const ratio = nonTestCodeFiles.length > 0 ? testFiles.length / nonTestCodeFiles.length : 0;
36
34
  const likelyGaps = nonTestCodeFiles
37
35
  .filter((file) => file.loc >= 260)
@@ -45,7 +43,7 @@ function buildCoverageInventory(ingest = {}) {
45
43
  path: file.path,
46
44
  loc: file.loc,
47
45
  language: file.language,
48
- severity: file.loc >= 550 ? "P1" : "P2",
46
+ severity: "P2",
49
47
  }));
50
48
 
51
49
  return {
@@ -0,0 +1,27 @@
1
+ export function resolveActionOptions(options = {}, command = null) {
2
+ const localOptions = options && typeof options.opts === "function" ? options.opts() : { ...options };
3
+ const parent = command?.parent;
4
+ if (!parent || typeof parent.opts !== "function") {
5
+ return localOptions;
6
+ }
7
+
8
+ const parentOptions = parent.opts();
9
+ const merged = {
10
+ ...parentOptions,
11
+ ...localOptions,
12
+ };
13
+
14
+ for (const [key, value] of Object.entries(parentOptions)) {
15
+ const parentSource =
16
+ typeof parent.getOptionValueSource === "function" ? parent.getOptionValueSource(key) : null;
17
+ const localSource =
18
+ typeof command?.getOptionValueSource === "function" ? command.getOptionValueSource(key) : null;
19
+ const parentWasProvided = parentSource && parentSource !== "default";
20
+ const localIsOnlyDefault = localSource === "default" || localOptions[key] === undefined;
21
+ if (parentWasProvided && localIsOnlyDefault) {
22
+ merged[key] = value;
23
+ }
24
+ }
25
+
26
+ return merged;
27
+ }