@oss-scout/core 0.10.0 → 1.0.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 (64) hide show
  1. package/dist/cli.bundle.cjs +77 -60
  2. package/dist/cli.js +403 -416
  3. package/dist/commands/command-scout.d.ts +21 -0
  4. package/dist/commands/command-scout.js +21 -0
  5. package/dist/commands/config.js +10 -128
  6. package/dist/commands/features.js +15 -28
  7. package/dist/commands/results.d.ts +13 -2
  8. package/dist/commands/results.js +29 -2
  9. package/dist/commands/search.d.ts +7 -0
  10. package/dist/commands/search.js +63 -68
  11. package/dist/commands/setup.d.ts +2 -0
  12. package/dist/commands/setup.js +35 -6
  13. package/dist/commands/skip.d.ts +4 -0
  14. package/dist/commands/skip.js +45 -55
  15. package/dist/commands/sync.d.ts +10 -0
  16. package/dist/commands/sync.js +10 -0
  17. package/dist/commands/vet-list.js +3 -19
  18. package/dist/commands/vet.js +18 -25
  19. package/dist/commands/with-scout.d.ts +32 -0
  20. package/dist/commands/with-scout.js +41 -0
  21. package/dist/core/anti-llm-policy.js +4 -5
  22. package/dist/core/bootstrap.d.ts +2 -2
  23. package/dist/core/bootstrap.js +5 -9
  24. package/dist/core/errors.d.ts +10 -0
  25. package/dist/core/errors.js +20 -5
  26. package/dist/core/feature-discovery.d.ts +13 -1
  27. package/dist/core/feature-discovery.js +104 -81
  28. package/dist/core/gist-state-store.d.ts +13 -12
  29. package/dist/core/gist-state-store.js +128 -53
  30. package/dist/core/http-cache.d.ts +32 -2
  31. package/dist/core/http-cache.js +74 -19
  32. package/dist/core/issue-discovery.d.ts +3 -0
  33. package/dist/core/issue-discovery.js +51 -31
  34. package/dist/core/issue-eligibility.d.ts +10 -4
  35. package/dist/core/issue-eligibility.js +119 -67
  36. package/dist/core/issue-graphql.d.ts +58 -0
  37. package/dist/core/issue-graphql.js +108 -0
  38. package/dist/core/issue-vetting.d.ts +105 -8
  39. package/dist/core/issue-vetting.js +234 -107
  40. package/dist/core/local-state.d.ts +6 -2
  41. package/dist/core/local-state.js +23 -5
  42. package/dist/core/logger.d.ts +12 -4
  43. package/dist/core/logger.js +33 -7
  44. package/dist/core/personalization.d.ts +51 -18
  45. package/dist/core/personalization.js +101 -27
  46. package/dist/core/preference-fields.d.ts +47 -0
  47. package/dist/core/preference-fields.js +178 -0
  48. package/dist/core/repo-health.js +31 -15
  49. package/dist/core/roadmap.js +17 -3
  50. package/dist/core/schemas.d.ts +144 -26
  51. package/dist/core/schemas.js +74 -17
  52. package/dist/core/search-budget.d.ts +9 -0
  53. package/dist/core/search-budget.js +36 -3
  54. package/dist/core/search-phases.d.ts +0 -18
  55. package/dist/core/search-phases.js +27 -82
  56. package/dist/core/types.d.ts +146 -30
  57. package/dist/core/utils.js +60 -26
  58. package/dist/formatters/markdown.d.ts +10 -0
  59. package/dist/formatters/markdown.js +31 -0
  60. package/dist/index.d.ts +6 -2
  61. package/dist/index.js +8 -0
  62. package/dist/scout.d.ts +59 -10
  63. package/dist/scout.js +244 -19
  64. package/package.json +1 -1
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * Personalization signals for search ranking (#1244).
3
3
  *
4
- * Translates caller-supplied `preferLanguages` / `preferRepos` lists
5
- * into a soft `boostScore` on each `IssueCandidate`. The final search
6
- * sort consults this score between the `recommendation` tier and the
7
- * raw `viabilityScore`, so personalization reorders ties without
8
- * changing which candidates pass vetting.
9
- *
10
- * This is the minimum-viable subset of Option A in #1244: only language
11
- * and repo bias, no `boostIssueTypes` / `avoidRepos` / `diversityRatio`
12
- * yet. Those follow up in separate PRs.
4
+ * Two passes:
5
+ *
6
+ * - `annotateBoost` translates `preferLanguages` / `preferRepos`
7
+ * into a soft `boostScore` consumed by issue-discovery's final
8
+ * sort tier between `recommendation` and `viabilityScore`.
9
+ * - `applyDiversityRatio` reserves a fraction of the final slot
10
+ * budget for candidates that matched no preference, counterweighting
11
+ * echo-chamber bias as recommendations accumulate over time.
12
+ *
13
+ * Still out of scope for #1244: `boostIssueTypes`, `avoidRepos`, and
14
+ * render-time annotation of `boostReasons` / `diversitySlot` in the CLI
15
+ * non-JSON output. Those follow up in separate PRs.
13
16
  */
14
17
  import type { IssueCandidate } from "./types.js";
15
18
  /**
@@ -25,15 +28,45 @@ import type { IssueCandidate } from "./types.js";
25
28
  export declare const REPO_BOOST = 20;
26
29
  export declare const LANGUAGE_BOOST = 10;
27
30
  /**
28
- * Annotate each candidate with `boostScore` and `boostReasons` based on
29
- * the caller-supplied preference lists. Mutates the array in place; the
30
- * caller is responsible for re-sorting afterwards.
31
+ * The personalization sort weight of a candidate: its boost score, or 0 when it
32
+ * is not boosted (unboosted or a diversity slot). Reads the structural
33
+ * `personalization` field (#158) so callers never poke at the old loose
34
+ * `boostScore` field.
35
+ */
36
+ export declare function boostScoreOf(candidate: IssueCandidate): number;
37
+ /**
38
+ * Return a new candidate list where each candidate that matches a
39
+ * caller-supplied preference carries `personalization: { kind: "boosted", ... }`.
40
+ * Does NOT mutate the input candidates (#158) — matched candidates are shallow
41
+ * copies with the field set; unmatched candidates are passed through unchanged.
42
+ * The caller re-sorts the returned array.
43
+ *
44
+ * No-op when both preference lists are empty or undefined: the input array is
45
+ * returned as-is and the sort tier collapses to 0 for every candidate.
46
+ */
47
+ export declare function annotateBoost(candidates: IssueCandidate[], preferLanguages?: string[], preferRepos?: string[]): IssueCandidate[];
48
+ /**
49
+ * Apply a diversity-counterweight pass over a pre-sorted candidate list
50
+ * (#1244). Returns the first `maxResults` picks in priority order:
51
+ *
52
+ * 1. Main slots: `maxResults - floor(maxResults * diversityRatio)`
53
+ * top candidates from the input. Personalization-biased candidates
54
+ * win these slots when present (since the input is already sorted
55
+ * by the personalization tier).
56
+ * 2. Diversity slots: the highest-ranked candidates that carry NO
57
+ * `boostScore` — i.e. they matched neither `preferLanguages` nor
58
+ * `preferRepos`. Tagged with `diversitySlot: true` for caller
59
+ * transparency.
60
+ * 3. Top-up: if the diversity pool was thinner than the reserve, fall
61
+ * back to the remaining sorted candidates so the user gets
62
+ * `maxResults` slots whenever the source has enough material.
31
63
  *
32
- * Mutation (rather than returning new objects) keeps the personalization
33
- * step a single linear pass over the array the caller already holds —
34
- * the sort step reads back from the same objects.
64
+ * `diversityRatio` is clamped to [0, 1]. 0 is a no-op (just slices the
65
+ * input). 1 means every slot is a diversity slot useful for
66
+ * deliberately suppressing personalization without disabling it.
35
67
  *
36
- * No-op when both preference lists are empty or undefined: candidates
37
- * retain `boostScore: undefined` and the sort tier collapses to 0.
68
+ * @param candidates Pre-sorted candidate list (output of issue-discovery)
69
+ * @param maxResults Total slots to fill
70
+ * @param diversityRatio Fraction of slots reserved for unboosted candidates
38
71
  */
39
- export declare function annotateBoost(candidates: IssueCandidate[], preferLanguages?: string[], preferRepos?: string[]): void;
72
+ export declare function applyDiversityRatio(candidates: IssueCandidate[], maxResults: number, diversityRatio: number): IssueCandidate[];
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * Personalization signals for search ranking (#1244).
3
3
  *
4
- * Translates caller-supplied `preferLanguages` / `preferRepos` lists
5
- * into a soft `boostScore` on each `IssueCandidate`. The final search
6
- * sort consults this score between the `recommendation` tier and the
7
- * raw `viabilityScore`, so personalization reorders ties without
8
- * changing which candidates pass vetting.
9
- *
10
- * This is the minimum-viable subset of Option A in #1244: only language
11
- * and repo bias, no `boostIssueTypes` / `avoidRepos` / `diversityRatio`
12
- * yet. Those follow up in separate PRs.
4
+ * Two passes:
5
+ *
6
+ * - `annotateBoost` translates `preferLanguages` / `preferRepos`
7
+ * into a soft `boostScore` consumed by issue-discovery's final
8
+ * sort tier between `recommendation` and `viabilityScore`.
9
+ * - `applyDiversityRatio` reserves a fraction of the final slot
10
+ * budget for candidates that matched no preference, counterweighting
11
+ * echo-chamber bias as recommendations accumulate over time.
12
+ *
13
+ * Still out of scope for #1244: `boostIssueTypes`, `avoidRepos`, and
14
+ * render-time annotation of `boostReasons` / `diversitySlot` in the CLI
15
+ * non-JSON output. Those follow up in separate PRs.
13
16
  */
14
17
  /**
15
18
  * Boost weights. Tuned conservatively so personalization tips equally-
@@ -24,37 +27,108 @@
24
27
  export const REPO_BOOST = 20;
25
28
  export const LANGUAGE_BOOST = 10;
26
29
  /**
27
- * Annotate each candidate with `boostScore` and `boostReasons` based on
28
- * the caller-supplied preference lists. Mutates the array in place; the
29
- * caller is responsible for re-sorting afterwards.
30
- *
31
- * Mutation (rather than returning new objects) keeps the personalization
32
- * step a single linear pass over the array the caller already holds —
33
- * the sort step reads back from the same objects.
30
+ * The personalization sort weight of a candidate: its boost score, or 0 when it
31
+ * is not boosted (unboosted or a diversity slot). Reads the structural
32
+ * `personalization` field (#158) so callers never poke at the old loose
33
+ * `boostScore` field.
34
+ */
35
+ export function boostScoreOf(candidate) {
36
+ return candidate.personalization?.kind === "boosted"
37
+ ? candidate.personalization.score
38
+ : 0;
39
+ }
40
+ /**
41
+ * Return a new candidate list where each candidate that matches a
42
+ * caller-supplied preference carries `personalization: { kind: "boosted", ... }`.
43
+ * Does NOT mutate the input candidates (#158) — matched candidates are shallow
44
+ * copies with the field set; unmatched candidates are passed through unchanged.
45
+ * The caller re-sorts the returned array.
34
46
  *
35
- * No-op when both preference lists are empty or undefined: candidates
36
- * retain `boostScore: undefined` and the sort tier collapses to 0.
47
+ * No-op when both preference lists are empty or undefined: the input array is
48
+ * returned as-is and the sort tier collapses to 0 for every candidate.
37
49
  */
38
50
  export function annotateBoost(candidates, preferLanguages, preferRepos) {
39
51
  const langSet = new Set((preferLanguages ?? []).map((l) => l.trim().toLowerCase()).filter(Boolean));
40
- const repoSet = new Set((preferRepos ?? []).map((r) => r.trim()).filter(Boolean));
52
+ const repoSet = new Set((preferRepos ?? []).map((r) => r.trim().toLowerCase()).filter(Boolean));
41
53
  if (langSet.size === 0 && repoSet.size === 0)
42
- return;
43
- for (const c of candidates) {
54
+ return candidates;
55
+ return candidates.map((c) => {
44
56
  let score = 0;
45
57
  const reasons = [];
46
- if (repoSet.size > 0 && repoSet.has(c.issue.repo)) {
58
+ if (repoSet.size > 0 && repoSet.has(c.issue.repo.toLowerCase())) {
47
59
  score += REPO_BOOST;
48
60
  reasons.push(`repo affinity: ${c.issue.repo}`);
49
61
  }
50
- const lang = c.projectHealth.language;
62
+ const lang = c.projectHealth.checkFailed ? null : c.projectHealth.language;
51
63
  if (langSet.size > 0 && lang && langSet.has(lang.toLowerCase())) {
52
64
  score += LANGUAGE_BOOST;
53
65
  reasons.push(`language match: ${lang}`);
54
66
  }
55
- if (score > 0) {
56
- c.boostScore = score;
57
- c.boostReasons = reasons;
58
- }
67
+ if (score === 0)
68
+ return c;
69
+ return { ...c, personalization: { kind: "boosted", score, reasons } };
70
+ });
71
+ }
72
+ /**
73
+ * Apply a diversity-counterweight pass over a pre-sorted candidate list
74
+ * (#1244). Returns the first `maxResults` picks in priority order:
75
+ *
76
+ * 1. Main slots: `maxResults - floor(maxResults * diversityRatio)`
77
+ * top candidates from the input. Personalization-biased candidates
78
+ * win these slots when present (since the input is already sorted
79
+ * by the personalization tier).
80
+ * 2. Diversity slots: the highest-ranked candidates that carry NO
81
+ * `boostScore` — i.e. they matched neither `preferLanguages` nor
82
+ * `preferRepos`. Tagged with `diversitySlot: true` for caller
83
+ * transparency.
84
+ * 3. Top-up: if the diversity pool was thinner than the reserve, fall
85
+ * back to the remaining sorted candidates so the user gets
86
+ * `maxResults` slots whenever the source has enough material.
87
+ *
88
+ * `diversityRatio` is clamped to [0, 1]. 0 is a no-op (just slices the
89
+ * input). 1 means every slot is a diversity slot — useful for
90
+ * deliberately suppressing personalization without disabling it.
91
+ *
92
+ * @param candidates Pre-sorted candidate list (output of issue-discovery)
93
+ * @param maxResults Total slots to fill
94
+ * @param diversityRatio Fraction of slots reserved for unboosted candidates
95
+ */
96
+ export function applyDiversityRatio(candidates, maxResults, diversityRatio) {
97
+ if (maxResults <= 0)
98
+ return [];
99
+ const ratio = Math.max(0, Math.min(1, diversityRatio));
100
+ if (ratio === 0)
101
+ return candidates.slice(0, maxResults);
102
+ const diversityReserve = Math.min(Math.floor(maxResults * ratio), maxResults);
103
+ if (diversityReserve === 0)
104
+ return candidates.slice(0, maxResults);
105
+ const mainBudget = maxResults - diversityReserve;
106
+ const picks = [];
107
+ const seen = new Set();
108
+ for (const c of candidates) {
109
+ if (picks.length >= mainBudget)
110
+ break;
111
+ picks.push(c);
112
+ seen.add(c.issue.url);
113
+ }
114
+ for (const c of candidates) {
115
+ if (picks.length >= maxResults)
116
+ break;
117
+ if (seen.has(c.issue.url))
118
+ continue;
119
+ if (boostScoreOf(c) > 0)
120
+ continue;
121
+ // Tag a shallow copy rather than mutating the shared candidate (#158).
122
+ picks.push({ ...c, personalization: { kind: "diversity" } });
123
+ seen.add(c.issue.url);
124
+ }
125
+ for (const c of candidates) {
126
+ if (picks.length >= maxResults)
127
+ break;
128
+ if (seen.has(c.issue.url))
129
+ continue;
130
+ picks.push(c);
131
+ seen.add(c.issue.url);
59
132
  }
133
+ return picks;
60
134
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared preference-field metadata and value parsing.
3
+ *
4
+ * The CLI (`commands/config.ts`) and the MCP `config-set` tool both update a
5
+ * single preference from a raw string. They used to carry separate, drifting
6
+ * copies of the key tables and parse logic — the CLI was missing the SLM
7
+ * triage keys, the MCP side lacked the `scope` special case and the +/- array
8
+ * syntax. This module is the single source of truth both drive (#153).
9
+ */
10
+ import type { ScoutPreferences } from "./schemas.js";
11
+ export type FieldConfig = {
12
+ type: "array" | "number" | "float" | "boolean" | "string";
13
+ } | {
14
+ type: "enum" | "enum-array";
15
+ validValues: readonly string[];
16
+ };
17
+ export declare const FIELD_CONFIGS: Record<string, FieldConfig>;
18
+ /**
19
+ * Every configurable preference key, derived from the schema so a new
20
+ * preference can't be silently left unconfigurable. `assertFieldConfigsCover`
21
+ * (exercised by a unit test) fails loudly if FIELD_CONFIGS drifts from this.
22
+ */
23
+ export declare const PREFERENCE_KEYS: readonly string[];
24
+ /** Sorted key list for "unknown key" error messages and help text. */
25
+ export declare const SORTED_PREFERENCE_KEYS: readonly string[];
26
+ /**
27
+ * Throw if any schema preference lacks a FIELD_CONFIG entry. Called from a
28
+ * test so adding a preference to the schema without teaching config-set how to
29
+ * parse it is caught in CI rather than at a user's first `config set newKey`.
30
+ */
31
+ export declare function assertFieldConfigsCover(): void;
32
+ /**
33
+ * Apply an array update: plain set, +append, or -remove.
34
+ *
35
+ * The -remove form starts with a dash, which commander rejects as an unknown
36
+ * option unless escaped: `config set excludeRepos -- "-spam/repo"`. The MCP
37
+ * tool has no commander layer so it can pass `-spam/repo` directly. Documented
38
+ * in the CLI help and README (#132).
39
+ */
40
+ export declare function updateArray(current: string[], value: string): string[];
41
+ /**
42
+ * Apply a single key/value update to a preferences object and return the
43
+ * fully validated result. The raw string `value` is the form both the CLI and
44
+ * the MCP tool receive; arrays accept comma-separated values and the +add /
45
+ * -remove syntax. Throws ValidationError on an unknown key or a bad value.
46
+ */
47
+ export declare function applyPreferenceField(preferences: ScoutPreferences, key: string, value: string): ScoutPreferences;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Shared preference-field metadata and value parsing.
3
+ *
4
+ * The CLI (`commands/config.ts`) and the MCP `config-set` tool both update a
5
+ * single preference from a raw string. They used to carry separate, drifting
6
+ * copies of the key tables and parse logic — the CLI was missing the SLM
7
+ * triage keys, the MCP side lacked the `scope` special case and the +/- array
8
+ * syntax. This module is the single source of truth both drive (#153).
9
+ */
10
+ import { ScoutPreferencesSchema, IssueScopeSchema, ProjectCategorySchema, PersistenceModeSchema, SearchStrategySchema, } from "./schemas.js";
11
+ import { ValidationError } from "./errors.js";
12
+ export const FIELD_CONFIGS = {
13
+ githubUsername: { type: "string" },
14
+ languages: { type: "array" },
15
+ labels: { type: "array" },
16
+ scope: { type: "enum-array", validValues: IssueScopeSchema.options },
17
+ excludeRepos: { type: "array" },
18
+ excludeOrgs: { type: "array" },
19
+ aiPolicyBlocklist: { type: "array" },
20
+ projectCategories: {
21
+ type: "enum-array",
22
+ validValues: ProjectCategorySchema.options,
23
+ },
24
+ minStars: { type: "number" },
25
+ maxIssueAgeDays: { type: "number" },
26
+ includeDocIssues: { type: "boolean" },
27
+ minRepoScoreThreshold: { type: "number" },
28
+ interPhaseDelayMs: { type: "number" },
29
+ persistence: { type: "enum", validValues: PersistenceModeSchema.options },
30
+ defaultStrategy: {
31
+ type: "enum-array",
32
+ validValues: SearchStrategySchema.options,
33
+ },
34
+ broadPhaseDelayMs: { type: "number" },
35
+ skipBroadWhenSufficientResults: { type: "number" },
36
+ preferLanguages: { type: "array" },
37
+ preferRepos: { type: "array" },
38
+ diversityRatio: { type: "float" },
39
+ slmTriageModel: { type: "string" },
40
+ slmTriageHost: { type: "string" },
41
+ featuresAnchorThreshold: { type: "number" },
42
+ featuresSplitRatio: { type: "float" },
43
+ };
44
+ /**
45
+ * Every configurable preference key, derived from the schema so a new
46
+ * preference can't be silently left unconfigurable. `assertFieldConfigsCover`
47
+ * (exercised by a unit test) fails loudly if FIELD_CONFIGS drifts from this.
48
+ */
49
+ export const PREFERENCE_KEYS = Object.keys(ScoutPreferencesSchema.shape);
50
+ /** Sorted key list for "unknown key" error messages and help text. */
51
+ export const SORTED_PREFERENCE_KEYS = [
52
+ ...PREFERENCE_KEYS,
53
+ ].sort();
54
+ /**
55
+ * Throw if any schema preference lacks a FIELD_CONFIG entry. Called from a
56
+ * test so adding a preference to the schema without teaching config-set how to
57
+ * parse it is caught in CI rather than at a user's first `config set newKey`.
58
+ */
59
+ export function assertFieldConfigsCover() {
60
+ const missing = PREFERENCE_KEYS.filter((k) => !(k in FIELD_CONFIGS));
61
+ if (missing.length > 0) {
62
+ throw new Error(`FIELD_CONFIGS is missing entries for preference keys: ${missing.join(", ")}`);
63
+ }
64
+ const extra = Object.keys(FIELD_CONFIGS).filter((k) => !PREFERENCE_KEYS.includes(k));
65
+ if (extra.length > 0) {
66
+ throw new Error(`FIELD_CONFIGS has entries for unknown preference keys: ${extra.join(", ")}`);
67
+ }
68
+ }
69
+ function parseBoolean(value) {
70
+ const lower = value.toLowerCase();
71
+ if (lower === "true" || lower === "yes")
72
+ return true;
73
+ if (lower === "false" || lower === "no")
74
+ return false;
75
+ throw new ValidationError(`Invalid boolean value: "${value}". Use true/false or yes/no.`);
76
+ }
77
+ function parseIntValue(value, key) {
78
+ const num = parseInt(value, 10);
79
+ if (isNaN(num)) {
80
+ throw new ValidationError(`Invalid number for "${key}": "${value}"`);
81
+ }
82
+ return num;
83
+ }
84
+ function parseFloatValue(value, key) {
85
+ const num = Number.parseFloat(value);
86
+ if (isNaN(num)) {
87
+ throw new ValidationError(`Invalid number for "${key}": "${value}"`);
88
+ }
89
+ return num;
90
+ }
91
+ function parseArrayValue(value) {
92
+ return value
93
+ .split(",")
94
+ .map((s) => s.trim())
95
+ .filter((s) => s.length > 0);
96
+ }
97
+ /**
98
+ * Apply an array update: plain set, +append, or -remove.
99
+ *
100
+ * The -remove form starts with a dash, which commander rejects as an unknown
101
+ * option unless escaped: `config set excludeRepos -- "-spam/repo"`. The MCP
102
+ * tool has no commander layer so it can pass `-spam/repo` directly. Documented
103
+ * in the CLI help and README (#132).
104
+ */
105
+ export function updateArray(current, value) {
106
+ if (value.startsWith("+")) {
107
+ const toAdd = parseArrayValue(value.slice(1));
108
+ const merged = [...current];
109
+ for (const item of toAdd) {
110
+ if (!merged.includes(item))
111
+ merged.push(item);
112
+ }
113
+ return merged;
114
+ }
115
+ if (value.startsWith("-")) {
116
+ const toRemove = new Set(parseArrayValue(value.slice(1)));
117
+ return current.filter((item) => !toRemove.has(item));
118
+ }
119
+ return parseArrayValue(value);
120
+ }
121
+ /**
122
+ * Apply a single key/value update to a preferences object and return the
123
+ * fully validated result. The raw string `value` is the form both the CLI and
124
+ * the MCP tool receive; arrays accept comma-separated values and the +add /
125
+ * -remove syntax. Throws ValidationError on an unknown key or a bad value.
126
+ */
127
+ export function applyPreferenceField(preferences, key, value) {
128
+ const field = FIELD_CONFIGS[key];
129
+ if (!field) {
130
+ throw new ValidationError(`Unknown config key: "${key}". Valid keys: ${SORTED_PREFERENCE_KEYS.join(", ")}`);
131
+ }
132
+ const prefs = { ...preferences };
133
+ switch (field.type) {
134
+ case "string":
135
+ prefs[key] = value;
136
+ break;
137
+ case "boolean":
138
+ prefs[key] = parseBoolean(value);
139
+ break;
140
+ case "number":
141
+ prefs[key] = parseIntValue(value, key);
142
+ break;
143
+ case "float":
144
+ prefs[key] = parseFloatValue(value, key);
145
+ break;
146
+ case "array": {
147
+ const current = prefs[key] ?? [];
148
+ prefs[key] = updateArray(current, value);
149
+ break;
150
+ }
151
+ case "enum": {
152
+ const validValues = field.validValues;
153
+ if (!validValues.includes(value)) {
154
+ throw new ValidationError(`Invalid value for "${key}": "${value}". Valid: ${validValues.join(", ")}`);
155
+ }
156
+ prefs[key] = value;
157
+ break;
158
+ }
159
+ case "enum-array": {
160
+ const current = prefs[key] ?? [];
161
+ const updated = updateArray(current, value);
162
+ const validValues = field.validValues;
163
+ const invalid = updated.filter((s) => !validValues.includes(s));
164
+ if (invalid.length > 0) {
165
+ throw new ValidationError(`Invalid value(s) for "${key}": ${invalid.join(", ")}. Valid: ${validValues.join(", ")}`);
166
+ }
167
+ // For 'scope', an empty array means undefined (all scopes).
168
+ if (key === "scope") {
169
+ prefs[key] = updated.length > 0 ? updated : undefined;
170
+ }
171
+ else {
172
+ prefs[key] = updated;
173
+ }
174
+ break;
175
+ }
176
+ }
177
+ return ScoutPreferencesSchema.parse(prefs);
178
+ }
@@ -5,7 +5,7 @@
5
5
  * from issue-level eligibility logic.
6
6
  */
7
7
  import { daysBetween } from "./utils.js";
8
- import { errorMessage, getHttpStatusCode, isRateLimitError } from "./errors.js";
8
+ import { errorMessage, getHttpStatusCode, isRateLimitError, rethrowIfFatal, } from "./errors.js";
9
9
  import { warn } from "./logger.js";
10
10
  import { getHttpCache, cachedRequest, cachedTimeBased } from "./http-cache.js";
11
11
  const MODULE = "repo-health";
@@ -73,19 +73,14 @@ export async function checkProjectHealth(octokit, owner, repo) {
73
73
  });
74
74
  }
75
75
  catch (error) {
76
- if (getHttpStatusCode(error) === 401 || isRateLimitError(error)) {
77
- throw error;
78
- }
76
+ rethrowIfFatal(error);
79
77
  const errMsg = errorMessage(error);
80
78
  warn(MODULE, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
79
+ // The check failed: only the repo and the reason are known. The
80
+ // discriminated ProjectHealth type intentionally has no place for the
81
+ // neutral-default snapshot fields this used to fabricate (#158).
81
82
  return {
82
83
  repo: `${owner}/${repo}`,
83
- lastCommitAt: "",
84
- daysSinceLastCommit: 999,
85
- openIssuesCount: 0,
86
- avgIssueResponseDays: 0,
87
- ciStatus: "unknown",
88
- isActive: false,
89
84
  checkFailed: true,
90
85
  failureReason: errMsg,
91
86
  };
@@ -104,6 +99,22 @@ export async function fetchContributionGuidelines(octokit, owner, repo) {
104
99
  if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
105
100
  return cached.guidelines;
106
101
  }
102
+ // Concurrent vets of issues from one repo share a single probe (#124)
103
+ const inflight = guidelinesInflight.get(cacheKey);
104
+ if (inflight)
105
+ return inflight;
106
+ const promise = fetchContributionGuidelinesUncached(octokit, owner, repo);
107
+ guidelinesInflight.set(cacheKey, promise);
108
+ try {
109
+ return await promise;
110
+ }
111
+ finally {
112
+ guidelinesInflight.delete(cacheKey);
113
+ }
114
+ }
115
+ const guidelinesInflight = new Map();
116
+ async function fetchContributionGuidelinesUncached(octokit, owner, repo) {
117
+ const cacheKey = `${owner}/${repo}`;
107
118
  const filesToCheck = [
108
119
  "CONTRIBUTING.md",
109
120
  ".github/CONTRIBUTING.md",
@@ -160,9 +171,13 @@ function parseContributionGuidelines(content) {
160
171
  rawContent: content,
161
172
  };
162
173
  const lowerContent = content.toLowerCase();
163
- // Detect branch naming conventions
174
+ // Detect branch naming conventions. CONTRIBUTING.md is attacker-controlled
175
+ // (it belongs to the repo being vetted): the unbounded [^\n]* pair forced
176
+ // quadratic backtracking on a long quote-less line, stalling the vet
177
+ // (#152). Bounded quantifiers keep the scan linear-ish; real conventions
178
+ // sit well inside 200 chars of their keyword.
164
179
  if (lowerContent.includes("branch")) {
165
- const branchMatch = content.match(/branch[^\n]*(?:named?|format|convention)[^\n]*[`"]([^`"]+)[`"]/i);
180
+ const branchMatch = content.match(/branch[^\n]{0,200}?(?:named?|format|convention)[^\n]{0,200}?[`"]([^`"\n]{1,100})[`"]/i);
166
181
  if (branchMatch) {
167
182
  guidelines.branchNamingConvention = branchMatch[1];
168
183
  }
@@ -172,7 +187,7 @@ function parseContributionGuidelines(content) {
172
187
  guidelines.commitMessageFormat = "conventional commits";
173
188
  }
174
189
  else if (lowerContent.includes("commit message")) {
175
- const commitMatch = content.match(/commit message[^\n]*[`"]([^`"]+)[`"]/i);
190
+ const commitMatch = content.match(/commit message[^\n]{0,200}?[`"]([^`"\n]{1,100})[`"]/i);
176
191
  if (commitMatch) {
177
192
  guidelines.commitMessageFormat = commitMatch[1];
178
193
  }
@@ -193,8 +208,9 @@ function parseContributionGuidelines(content) {
193
208
  guidelines.linter = "RuboCop";
194
209
  else if (lowerContent.includes("prettier"))
195
210
  guidelines.formatter = "Prettier";
196
- // Detect CLA requirement
197
- if (lowerContent.includes("cla") ||
211
+ // Detect CLA requirement. Word boundary matters: a bare substring check
212
+ // matches "class", "clang", "clarify", etc. and flags nearly every doc.
213
+ if (/\bcla\b/.test(lowerContent) ||
198
214
  lowerContent.includes("contributor license agreement")) {
199
215
  guidelines.claRequired = true;
200
216
  }
@@ -10,7 +10,7 @@
10
10
  * Auth (401) and rate-limit errors propagate, matching the rest of the
11
11
  * codebase's error strategy. Other errors degrade gracefully (warn + empty).
12
12
  */
13
- import { errorMessage, getHttpStatusCode, isRateLimitError } from "./errors.js";
13
+ import { errorMessage, getHttpStatusCode, rethrowIfFatal } from "./errors.js";
14
14
  import { warn } from "./logger.js";
15
15
  const MODULE = "roadmap";
16
16
  /** TTL for roadmap fetch results (1 hour). */
@@ -97,6 +97,21 @@ export async function fetchRoadmapIssueRefs(octokit, owner, repo) {
97
97
  if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
98
98
  return cached.refs;
99
99
  }
100
+ // Concurrent feature vets of issues from one repo share a probe (#124)
101
+ const inflight = roadmapInflight.get(cacheKey);
102
+ if (inflight)
103
+ return inflight;
104
+ const promise = fetchRoadmapIssueRefsUncached(octokit, owner, repo, cacheKey);
105
+ roadmapInflight.set(cacheKey, promise);
106
+ try {
107
+ return await promise;
108
+ }
109
+ finally {
110
+ roadmapInflight.delete(cacheKey);
111
+ }
112
+ }
113
+ const roadmapInflight = new Map();
114
+ async function fetchRoadmapIssueRefsUncached(octokit, owner, repo, cacheKey) {
100
115
  for (const path of ROADMAP_PATHS) {
101
116
  try {
102
117
  const { data } = await octokit.repos.getContent({ owner, repo, path });
@@ -109,8 +124,7 @@ export async function fetchRoadmapIssueRefs(octokit, owner, repo) {
109
124
  return refs;
110
125
  }
111
126
  catch (err) {
112
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
113
- throw err;
127
+ rethrowIfFatal(err);
114
128
  const status = getHttpStatusCode(err);
115
129
  if (status === 404)
116
130
  continue; // path missing — try next