github-portfolio-analyzer 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.4.0] — 2026-04-03
8
+
9
+ ### Added
10
+ - Smoke tests for `--version` and `--help`
11
+ - Analyze command integration coverage for env-token execution and generated inventory fields
12
+ - `dormant` state in report summaries and documentation, while preserving manual `abandoned` compatibility
13
+
14
+ ### Changed
15
+ - Automatic inactivity classification now returns `dormant` instead of `abandoned`
16
+ - Repository taxonomy inference now prioritizes `experiment` before `library`, broadens `learning` signals, and classifies simple apps like clocks/calculators/games as `product`
17
+ - Fork fallback classification now uses recent fork activity when upstream compare metadata is unavailable
18
+
7
19
  ## [1.3.0] — 2026-04-03
8
20
 
9
21
  ### Added
package/README.md CHANGED
@@ -495,7 +495,8 @@ Each `portfolio.json.items[]` entry includes:
495
495
 
496
496
  - `type`: `repo | idea`
497
497
  - `category`: `product | tooling | library | learning | content | infra | experiment | template`
498
- - `state`: `idea | active | stale | abandoned | archived | reference-only`
498
+ - `state`: `idea | active | stale | dormant | abandoned | archived | reference-only`
499
+ - Auto-classified repository inactivity uses `dormant`; `abandoned` remains supported for manual curation.
499
500
  - `strategy`: `strategic-core | strategic-support | opportunistic | maintenance | parked`
500
501
  - `effort`: `xs | s | m | l | xl`
501
502
  - `value`: `low | medium | high | very-high`
@@ -521,11 +522,11 @@ Every repository passes through a deterministic scoring pipeline:
521
522
  flowchart LR
522
523
  subgraph top [ ]
523
524
  direction LR
524
- A([repo metadata]) --> B(inferRepoCategory) --> C([category]) --> D(scoreRepository) --> E([score 0100])
525
+ A([repo metadata]) --> B(inferRepoCategory) --> C([category]) --> D(scoreRepository) --> E([score 0-100])
525
526
  end
526
527
  subgraph mid [ ]
527
528
  direction RL
528
- J(computePriorityBand) <-- I([effort xsxl]) <-- H(computeEffortEstimate) <-- G([CL 05]) <-- F(computeCompletionLevel)
529
+ J(computePriorityBand) <-- I([effort xs-xl]) <-- H(computeEffortEstimate) <-- G([CL 0-5]) <-- F(computeCompletionLevel)
529
530
  end
530
531
  E --> F
531
532
  E -. feeds .-> J
@@ -606,7 +607,7 @@ final `priorityScore`, which determines the band.
606
607
  |---|---|---|
607
608
  | State boost | `active` | +10 |
608
609
  | State boost | `stale` | +5 |
609
- | State penalty | `abandoned` or `archived` | −20 |
610
+ | State penalty | `dormant`, `abandoned`, or `archived` | −20 |
610
611
  | Quick-win boost | CL 1, 2, or 3 | +10 |
611
612
  | Effort penalty | `l` or `xl` | −10 |
612
613
 
@@ -614,7 +615,7 @@ final `priorityScore`, which determines the band.
614
615
 
615
616
  | Band | Range | Meaning |
616
617
  |---|---|---|
617
- | `park` | < 45 | Needs a decision before any investment. Abandoned, low signal, or intentionally paused. |
618
+ | `park` | < 45 | Needs a decision before any investment. Dormant, low signal, or intentionally paused. |
618
619
  | `later` | 45–64 | Viable but not urgent. Can return when backlog has room. |
619
620
  | `next` | 65–79 | Strong candidate. High score but large effort, or active with average score. |
620
621
  | `now` | ≥ 80 | High confidence. Active project, good score, low effort — or manually pinned. |
@@ -633,7 +634,7 @@ updated 200d ago +0 (> 180 days)
633
634
  ──────────────────────────────────
634
635
  score 25
635
636
 
636
- state=abandoned −20
637
+ state=dormant −20
637
638
  effort=xl −10
638
639
  ──────────────────────────────────
639
640
  priorityScore −5 → park
@@ -1,11 +1,21 @@
1
1
  {
2
2
  "name": "github-portfolio-analyzer",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "commands": [
5
5
  { "id": "analyze" },
6
6
  { "id": "ingest-ideas" },
7
7
  { "id": "build-portfolio" },
8
- { "id": "report", "flags": ["--policy", "--explain", "--output", "--format", "--quiet"] }
8
+ {
9
+ "id": "report",
10
+ "flags": [
11
+ "--policy",
12
+ "--explain",
13
+ "--output",
14
+ "--format",
15
+ "--quiet",
16
+ "--presentation-overrides"
17
+ ]
18
+ }
9
19
  ],
10
20
  "outputs": [
11
21
  "output/inventory.json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-portfolio-analyzer",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI tool to analyze GitHub repos and portfolio ideas",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,10 +51,11 @@
51
51
  "byState": {
52
52
  "type": "object",
53
53
  "additionalProperties": true,
54
- "required": ["active", "stale", "abandoned", "archived", "idea", "reference-only"],
54
+ "required": ["active", "stale", "dormant", "abandoned", "archived", "idea", "reference-only"],
55
55
  "properties": {
56
56
  "active": { "type": "number" },
57
57
  "stale": { "type": "number" },
58
+ "dormant": { "type": "number" },
58
59
  "abandoned": { "type": "number" },
59
60
  "archived": { "type": "number" },
60
61
  "idea": { "type": "number" },
package/src/cli.js CHANGED
@@ -20,7 +20,7 @@ const COMMAND_OPTIONS = {
20
20
  analyze: new Set(['as-of', 'output-dir']),
21
21
  'ingest-ideas': new Set(['input', 'prompt', 'output-dir']),
22
22
  'build-portfolio': new Set(['output-dir']),
23
- report: new Set(['output-dir', 'output', 'format', 'policy', 'priorities', 'explain', 'quiet'])
23
+ report: new Set(['output-dir', 'output', 'format', 'policy', 'priorities', 'explain', 'quiet', 'presentation-overrides'])
24
24
  };
25
25
 
26
26
  export async function runCli(argv) {
@@ -97,6 +97,8 @@ function printHelp() {
97
97
  console.log(' --format VALUE ascii|md|json|all (default: all)');
98
98
  console.log(' --policy PATH Optional policy overlay JSON file');
99
99
  console.log(' --priorities PATH Alias for --policy');
100
+ console.log(' --presentation-overrides PATH');
101
+ console.log(' Optional presentation overrides JSON file');
100
102
  console.log(' --explain Print NOW ranking explainability to console');
101
103
  console.log(' --quiet Suppress non-error logs');
102
104
  }
@@ -22,12 +22,7 @@ export async function runAnalyzeCommand(options = {}) {
22
22
  required: [
23
23
  { key: 'githubToken', label: 'GitHub Personal Access Token' }
24
24
  ],
25
- optional: [
26
- { key: 'githubUsername', label: 'GitHub Username' },
27
- { key: 'openaiKey', label: 'OpenAI API Key' },
28
- { key: 'geminiKey', label: 'Gemini API Key' },
29
- { key: 'anthropicKey', label: 'Anthropic API Key' }
30
- ]
25
+ optional: []
31
26
  });
32
27
 
33
28
  let token;
@@ -65,7 +60,7 @@ export async function runAnalyzeCommand(options = {}) {
65
60
 
66
61
  let repositories;
67
62
  try {
68
- repositories = await fetchAllRepositories(github);
63
+ repositories = await fetchAllRepositories(github, asOfDate);
69
64
  } catch (err) {
70
65
  if (err && (err.status === 401 || err.status === 403)) {
71
66
  fatal('GitHub authentication failed — check your GITHUB_TOKEN permissions');
@@ -235,7 +235,7 @@ function computeStateAdjustment(state) {
235
235
  return 5;
236
236
  }
237
237
 
238
- if (state === 'abandoned' || state === 'archived') {
238
+ if (state === 'dormant' || state === 'abandoned' || state === 'archived') {
239
239
  return -20;
240
240
  }
241
241
 
@@ -11,7 +11,7 @@ export function classifyActivity(pushedAt, asOfDate) {
11
11
  return 'stale';
12
12
  }
13
13
 
14
- return 'abandoned';
14
+ return 'dormant';
15
15
  }
16
16
 
17
17
  export function classifyMaturity(sizeKb) {
@@ -1,6 +1,6 @@
1
1
  import { utcNowISOString } from '../utils/time.js';
2
2
 
3
- const STATE_ORDER = ['active', 'stale', 'abandoned', 'archived', 'idea', 'reference-only'];
3
+ const STATE_ORDER = ['active', 'stale', 'dormant', 'abandoned', 'archived', 'idea', 'reference-only'];
4
4
  const EFFORT_ORDER = ['xs', 's', 'm', 'l', 'xl'];
5
5
  const BAND_STRENGTH = {
6
6
  park: 0,
@@ -109,9 +109,9 @@ export function computePriorityBand(input) {
109
109
  } else if (state === 'stale') {
110
110
  priorityScore += 5;
111
111
  reasons.push('State boost: stale (+5)');
112
- } else if (state === 'abandoned' || state === 'archived') {
112
+ } else if (state === 'dormant' || state === 'abandoned' || state === 'archived') {
113
113
  priorityScore -= 20;
114
- reasons.push('State penalty: abandoned/archived (-20)');
114
+ reasons.push('State penalty: dormant/abandoned/archived (-20)');
115
115
  }
116
116
 
117
117
  if (completionLevel >= 1 && completionLevel <= 3) {
@@ -309,6 +309,7 @@ function buildByStateSummary(reportItems) {
309
309
  const summary = {
310
310
  active: 0,
311
311
  stale: 0,
312
+ dormant: 0,
312
313
  abandoned: 0,
313
314
  archived: 0,
314
315
  idea: 0,
@@ -6,13 +6,14 @@ function inferRepoCategory(repository) {
6
6
  const topics = Array.isArray(repository.topics)
7
7
  ? repository.topics.map((topic) => String(topic).toLowerCase())
8
8
  : [];
9
+ const nameAndTopics = [name, ...topics].join(' ');
9
10
  const all = [name, desc, ...topics].join(' ');
10
11
 
11
12
  if (/\b(prompt|note|notes|snippet|snippets|cheatsheet|doc|docs|documentation|knowledge|wiki|resource|resources|writing|content|guide|guides|cookbook)\b/.test(all)) {
12
13
  return 'content';
13
14
  }
14
15
 
15
- if (/\b(learn|learning|study|exercise|exercises|course|tutorial|tutorials|practice|training|bootcamp|challenge|challenges|kata)\b/.test(all)) {
16
+ if (/\b(learn|learning|study|exercise|exercises|course|tutorial|tutorials|practice|training|bootcamp|challenge|challenges|kata|curriculum|syllabus)\b/.test(all)) {
16
17
  return 'learning';
17
18
  }
18
19
 
@@ -20,6 +21,10 @@ function inferRepoCategory(repository) {
20
21
  return 'template';
21
22
  }
22
23
 
24
+ if (/\b(poc|proof|experiment|spike|prototype|sandbox|playground)\b/.test(nameAndTopics)) {
25
+ return 'experiment';
26
+ }
27
+
23
28
  if (/\b(lib|library|sdk|package|npm|module|plugin|extension|addon|util|utils|helper|helpers)\b/.test(all)) {
24
29
  return 'library';
25
30
  }
@@ -28,11 +33,11 @@ function inferRepoCategory(repository) {
28
33
  return 'infra';
29
34
  }
30
35
 
31
- if (/\b(poc|proof|experiment|spike|demo|prototype|sandbox|playground|try|trying)\b/.test(all)) {
36
+ if (/\b(demo|try|trying)\b/.test(all)) {
32
37
  return 'experiment';
33
38
  }
34
39
 
35
- if (/\b(app|application|system|platform|service|api|backend|frontend|web|mobile|dashboard|portal|saas)\b/.test(all)) {
40
+ if (/\b(app|application|system|platform|service|api|backend|frontend|web|mobile|dashboard|portal|saas|clock|calculator|game|games|viewer|weather|timer|todo|player|tracker)\b/.test(all)) {
36
41
  return 'product';
37
42
  }
38
43
 
@@ -130,6 +135,10 @@ export function mapIdeaStatusToState(status) {
130
135
  return 'stale';
131
136
  }
132
137
 
138
+ if (value === 'dormant') {
139
+ return 'dormant';
140
+ }
141
+
133
142
  if (value === 'abandoned' || value === 'dropped') {
134
143
  return 'abandoned';
135
144
  }
@@ -154,7 +163,7 @@ function defaultRepoNextAction(state) {
154
163
  return formatNextAction('Refresh', 'execution documentation', 'README run steps are validated in a clean environment');
155
164
  }
156
165
 
157
- if (state === 'abandoned') {
166
+ if (state === 'dormant' || state === 'abandoned') {
158
167
  return formatNextAction('Decide', 'retain or archive status', 'README contains a documented decision and rationale');
159
168
  }
160
169
 
@@ -170,7 +179,7 @@ function normalizeCategory(value) {
170
179
  }
171
180
 
172
181
  function normalizeState(value, fallback) {
173
- return normalizeEnum(value, ['idea', 'active', 'stale', 'abandoned', 'archived', 'reference-only']) ?? fallback;
182
+ return normalizeEnum(value, ['idea', 'active', 'stale', 'dormant', 'abandoned', 'archived', 'reference-only']) ?? fallback;
174
183
  }
175
184
 
176
185
  function normalizeStrategy(value) {
@@ -15,6 +15,7 @@ export class GithubClient {
15
15
  constructor(token) {
16
16
  this.token = token;
17
17
  this.maxRetries = 4;
18
+ this._delay = sleepMs;
18
19
  }
19
20
 
20
21
  async request(path, init = {}) {
@@ -42,7 +43,7 @@ export class GithubClient {
42
43
  responseHeaders: response.headers,
43
44
  attempt
44
45
  });
45
- await sleepMs(delayMs);
46
+ await this._delay(delayMs);
46
47
  continue;
47
48
  }
48
49
 
@@ -37,8 +37,12 @@ async function readPackageJson(client, repository) {
37
37
  try {
38
38
  const response = await client.request(repoApiPath(repository, 'contents/package.json'));
39
39
  if (response?.content && response?.encoding === 'base64') {
40
- const decoded = Buffer.from(response.content, 'base64').toString('utf8');
41
- return JSON.parse(decoded);
40
+ try {
41
+ const decoded = Buffer.from(response.content, 'base64').toString('utf8');
42
+ return JSON.parse(decoded);
43
+ } catch {
44
+ return null;
45
+ }
42
46
  }
43
47
  return null;
44
48
  } catch (error) {
@@ -1,17 +1,24 @@
1
+ import { daysSince } from '../core/classification.js';
2
+
1
3
  const PAGE_SIZE = 100;
2
4
 
3
5
  /**
4
6
  * Classifies a fork as active or passive.
5
- * Active forks have commits ahead of the upstream default branch.
7
+ * Active forks are either ahead of the upstream default branch or recently active
8
+ * when upstream comparison metadata is unavailable.
6
9
  */
7
- export async function classifyFork(client, repo) {
10
+ export async function classifyFork(client, repo, asOfDate = new Date().toISOString().slice(0, 10)) {
8
11
  if (!repo?.fork) {
9
12
  return null;
10
13
  }
11
14
 
12
15
  const parent = repo.parent;
16
+ const pushedAt = repo._pushedAt ?? repo.pushed_at ?? repo.pushedAt ?? null;
17
+ const isRecentlyActive = pushedAt ? daysSince(pushedAt, asOfDate) <= 90 : false;
18
+ const fallbackForkType = isRecentlyActive ? 'active' : 'passive';
19
+
13
20
  if (!parent) {
14
- return 'passive';
21
+ return fallbackForkType;
15
22
  }
16
23
 
17
24
  const ownerLogin = repo.owner?.login ?? repo.ownerLogin;
@@ -20,7 +27,7 @@ export async function classifyFork(client, repo) {
20
27
  const branch = repo.default_branch ?? repo.defaultBranch ?? 'main';
21
28
 
22
29
  if (!ownerLogin || !parentOwner || !repo.name) {
23
- return 'passive';
30
+ return fallbackForkType;
24
31
  }
25
32
 
26
33
  try {
@@ -30,11 +37,11 @@ export async function classifyFork(client, repo) {
30
37
 
31
38
  return (comparison?.ahead_by ?? 0) > 0 ? 'active' : 'passive';
32
39
  } catch {
33
- return 'passive';
40
+ return fallbackForkType;
34
41
  }
35
42
  }
36
43
 
37
- export async function fetchAllRepositories(client) {
44
+ export async function fetchAllRepositories(client, asOfDate = new Date().toISOString().slice(0, 10)) {
38
45
  const repositories = [];
39
46
 
40
47
  for (let page = 1; ; page += 1) {
@@ -66,7 +73,7 @@ export async function fetchAllRepositories(client) {
66
73
  const batch = forks.slice(index, index + 5);
67
74
  await Promise.all(
68
75
  batch.map(async (repository) => {
69
- repository.forkType = await classifyFork(client, repository);
76
+ repository.forkType = await classifyFork(client, repository, asOfDate);
70
77
  })
71
78
  );
72
79
  }
@@ -109,7 +109,7 @@ function renderPortfolioSummary(payload) {
109
109
 
110
110
  lines.push('');
111
111
 
112
- for (const state of ['active', 'stale', 'abandoned', 'idea']) {
112
+ for (const state of ['active', 'stale', 'dormant', 'abandoned', 'idea']) {
113
113
  lines.push(`## State: ${state}`);
114
114
  lines.push('');
115
115
 
package/src/utils/time.js CHANGED
@@ -14,6 +14,9 @@ export function resolveAsOfDate(input) {
14
14
  if (Number.isNaN(asDate.getTime())) {
15
15
  throw new Error(`Invalid --as-of value: ${input}. Expected YYYY-MM-DD.`);
16
16
  }
17
+ if (asDate.toISOString().slice(0, 10) !== input) {
18
+ throw new Error(`Invalid --as-of value: ${input}. Date does not exist in the calendar.`);
19
+ }
17
20
 
18
21
  return input;
19
22
  }