@opengsd/gsd-core 1.3.1 → 1.4.0-rc.1

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 (97) hide show
  1. package/agents/gsd-advisor-researcher.md +1 -20
  2. package/agents/gsd-ai-researcher.md +1 -20
  3. package/agents/gsd-domain-researcher.md +1 -20
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-phase-researcher.md +92 -166
  6. package/agents/gsd-planner.md +9 -36
  7. package/agents/gsd-project-researcher.md +62 -141
  8. package/agents/gsd-ui-researcher.md +2 -21
  9. package/agents/gsd-verifier.md +8 -2
  10. package/bin/install.js +85 -4
  11. package/commands/gsd/graphify.md +11 -6
  12. package/commands/gsd/import.md +6 -2
  13. package/commands/gsd/plan-phase.md +2 -2
  14. package/gsd-core/bin/check-latest-version.cjs +3 -2
  15. package/gsd-core/bin/gsd-tools.cjs +238 -32
  16. package/gsd-core/bin/lib/check-command-router.cjs +1 -0
  17. package/gsd-core/bin/lib/cli-exit.cjs +42 -0
  18. package/gsd-core/bin/lib/command-routing-hub.cjs +1 -1
  19. package/gsd-core/bin/lib/commands.cjs +5 -4
  20. package/gsd-core/bin/lib/config.cjs +28 -4
  21. package/gsd-core/bin/lib/core.cjs +72 -28
  22. package/gsd-core/bin/lib/graphify.cjs +2 -2
  23. package/gsd-core/bin/lib/init-command-router.cjs +2 -2
  24. package/gsd-core/bin/lib/init.cjs +19 -3
  25. package/gsd-core/bin/lib/intel.cjs +3 -20
  26. package/gsd-core/bin/lib/package-legitimacy.cjs +368 -0
  27. package/gsd-core/bin/lib/phase.cjs +3 -3
  28. package/gsd-core/bin/lib/research-provider.cjs +137 -0
  29. package/gsd-core/bin/lib/research-store.cjs +167 -0
  30. package/gsd-core/bin/lib/roadmap-upgrade.cjs +4 -19
  31. package/gsd-core/bin/lib/security.cjs +73 -0
  32. package/gsd-core/bin/lib/shell-command-projection.cjs +3 -0
  33. package/gsd-core/bin/lib/validate.cjs +2 -2
  34. package/gsd-core/bin/lib/verification-command-router.cjs +31 -0
  35. package/gsd-core/bin/lib/verification.cjs +193 -0
  36. package/gsd-core/bin/lib/verify.cjs +2 -2
  37. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -1
  38. package/gsd-core/bin/lib/worktree-base-ref.cjs +325 -0
  39. package/gsd-core/bin/lib/worktree-safety.cjs +31 -0
  40. package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
  41. package/gsd-core/bin/verify-reapply-patches.cjs +8 -11
  42. package/gsd-core/references/planner-load-graph-context.md +36 -0
  43. package/gsd-core/references/planning-config.md +3 -1
  44. package/gsd-core/references/research-documentation-lookup.md +29 -0
  45. package/gsd-core/references/research-philosophy.md +29 -0
  46. package/gsd-core/references/research-verification-protocol.md +27 -0
  47. package/gsd-core/workflows/execute-phase.md +19 -8
  48. package/gsd-core/workflows/help/modes/full.md +2 -2
  49. package/gsd-core/workflows/ingest-docs.md +3 -2
  50. package/gsd-core/workflows/plan-phase.md +14 -10
  51. package/gsd-core/workflows/plan-review-convergence.md +3 -3
  52. package/gsd-core/workflows/review.md +22 -5
  53. package/gsd-core/workflows/ship.md +5 -8
  54. package/gsd-core/workflows/spec-phase.md +2 -1
  55. package/gsd-core/workflows/update.md +2 -1
  56. package/hooks/dist/gsd-context-monitor.js +1 -1
  57. package/hooks/dist/gsd-workflow-guard.js +1 -0
  58. package/hooks/dist/gsd-worktree-path-guard.js +1 -1
  59. package/hooks/gsd-context-monitor.js +1 -1
  60. package/hooks/gsd-workflow-guard.js +1 -0
  61. package/hooks/gsd-worktree-path-guard.js +1 -1
  62. package/package.json +4 -1
  63. package/scripts/affected-tests-lib.cjs +3 -2
  64. package/scripts/changeset/cli.cjs +183 -28
  65. package/scripts/changeset/lint.cjs +5 -4
  66. package/scripts/changeset/new.cjs +4 -4
  67. package/scripts/check-alias-drift.cjs +77 -71
  68. package/scripts/check-env.cjs +185 -179
  69. package/scripts/check-npm-integrity.cjs +115 -109
  70. package/scripts/ci-guard-runner.cjs +11 -5
  71. package/scripts/ci-prepare-test-scope.cjs +27 -22
  72. package/scripts/ci-rebase-check.cjs +46 -45
  73. package/scripts/ci-test-scope.cjs +6 -4
  74. package/scripts/diff-touches-shipped-paths.cjs +52 -44
  75. package/scripts/gen-inventory-manifest.cjs +38 -32
  76. package/scripts/gen-research-agents.cjs +276 -0
  77. package/scripts/lib/cli-exit.cjs +56 -0
  78. package/scripts/lint-command-contract.cjs +28 -22
  79. package/scripts/lint-descriptions.cjs +32 -28
  80. package/scripts/lint-docs-required.cjs +4 -4
  81. package/scripts/lint-legacy-dir-name.cjs +56 -52
  82. package/scripts/lint-pr-check-project-dir.cjs +3 -1
  83. package/scripts/lint-shell-command-projection-drift.cjs +27 -22
  84. package/scripts/lint-skill-deps.cjs +31 -26
  85. package/scripts/lint-test-file-count.allowlist.json +1 -0
  86. package/scripts/lint-test-file-count.cjs +5 -4
  87. package/scripts/mutation-matrix.cjs +6 -3
  88. package/scripts/prompt-injection-scan.sh +1 -1
  89. package/scripts/release-notes/format-github-release-notes.cjs +8 -3
  90. package/scripts/release-tarball-smoke.cjs +6 -4
  91. package/scripts/research-profiles.cjs +149 -0
  92. package/scripts/run-affected-tests.cjs +2 -1
  93. package/scripts/run-cross-platform-tests.cjs +11 -7
  94. package/scripts/run-tests.cjs +8 -7
  95. package/scripts/strip-prose-atrefs.cjs +1 -1
  96. package/scripts/sync-runtime-launcher.cjs +0 -3
  97. package/scripts/verify-npm-publish.cjs +14 -26
@@ -506,6 +506,7 @@ function loadConfig(cwd, options = {}) {
506
506
  effort: (parsed['effort']) || null,
507
507
  fast_mode: (parsed['fast_mode']) || null,
508
508
  agent_skills: (parsed['agent_skills']) || {},
509
+ agent_skills_security: (parsed['agent_skills_security']) || null,
509
510
  manager: (parsed['manager']) || {},
510
511
  response_language: get('response_language') || null,
511
512
  claude_md_path: get('claude_md_path') || null,
@@ -994,35 +995,38 @@ function extractCurrentMilestone(content, cwd) {
994
995
  const firstMatch = allMatches[0];
995
996
  const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
996
997
  const sectionStart = selected.index;
997
- const sectionMatch = selected;
998
- const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
999
- const restContent = content.slice(sectionStart + sectionMatch[0].length);
1000
- const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
1001
- let sectionEnd = content.length;
1002
- let fenceChar = null;
1003
- let fenceLen = 0;
1004
- let charOffset = 0;
1005
- for (const line of restContent.split('\n')) {
1006
- const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
1007
- if (fenceMatch) {
1008
- const char = fenceMatch[1][0];
1009
- const len = fenceMatch[1].length;
1010
- const trailing = fenceMatch[2] || '';
1011
- if (!fenceChar) {
1012
- fenceChar = char;
1013
- fenceLen = len;
998
+ const computeSectionEnd = (headingText, headingStart) => {
999
+ const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
1000
+ const rest = content.slice(headingStart + headingText.length);
1001
+ const stopPattern = new RegExp(`^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
1002
+ let end = content.length;
1003
+ let fc = null;
1004
+ let fl = 0;
1005
+ let off = 0;
1006
+ for (const line of rest.split('\n')) {
1007
+ const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
1008
+ if (fm) {
1009
+ const ch = fm[1][0];
1010
+ const ln = fm[1].length;
1011
+ const trailing = fm[2] || '';
1012
+ if (!fc) {
1013
+ fc = ch;
1014
+ fl = ln;
1015
+ }
1016
+ else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) {
1017
+ fc = null;
1018
+ fl = 0;
1019
+ }
1014
1020
  }
1015
- else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
1016
- fenceChar = null;
1017
- fenceLen = 0;
1021
+ else if (!fc && stopPattern.test(line)) {
1022
+ end = headingStart + headingText.length + off;
1023
+ break;
1018
1024
  }
1025
+ off += line.length + 1;
1019
1026
  }
1020
- else if (!fenceChar && nextMilestonePattern.test(line)) {
1021
- sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
1022
- break;
1023
- }
1024
- charOffset += line.length + 1;
1025
- }
1027
+ return end;
1028
+ };
1029
+ const sectionEnd = computeSectionEnd(selected[0], sectionStart);
1026
1030
  const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
1027
1031
  const firstMilestoneMatch = content.match(anyMilestonePattern);
1028
1032
  const preambleCutoff = firstMilestoneMatch
@@ -1030,11 +1034,36 @@ function extractCurrentMilestone(content, cwd) {
1030
1034
  : firstMatch.index;
1031
1035
  const beforeMilestones = content.slice(0, preambleCutoff);
1032
1036
  const currentSection = content.slice(sectionStart, sectionEnd);
1037
+ // Multi-milestone roadmaps split each added milestone across two version-bearing
1038
+ // headings: a `## Phases` checklist subsection (early) and a dedicated
1039
+ // `## Milestone … (Phase Details)` section (late) holding the `### Phase N:`
1040
+ // detail headers. The scope window above stops at the next version-bearing
1041
+ // heading — the current milestone's OWN Phase Details heading — leaving those
1042
+ // detail headers outside `currentSection`. Append that section so phase
1043
+ // resolution and counting see the current milestone's phases. Anchor the lookup
1044
+ // to the SELECTED heading's specific version token (boundary-aware, so a
1045
+ // `v3.0` state does not match a `v3.0-A` sub-milestone) so sibling milestones
1046
+ // that share a version prefix do not cross-pollinate. (#730)
1047
+ const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
1048
+ const detailsVersionBoundary = selectedVersionToken
1049
+ ? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
1050
+ : null;
1051
+ let detailsSection = '';
1052
+ const detailsMatch = allMatches.find((m) => /\(Phase\s+Details\)/i.test(m[1]) &&
1053
+ !isClosed(m[1]) &&
1054
+ (!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) &&
1055
+ (m.index ?? 0) >= sectionEnd);
1056
+ if (detailsMatch) {
1057
+ const detailsStart = detailsMatch.index ?? 0;
1058
+ detailsSection = content.slice(detailsStart, computeSectionEnd(detailsMatch[0], detailsStart));
1059
+ }
1033
1060
  const preamble = beforeMilestones
1034
1061
  .replace(/<details>[\s\S]*?<\/details>/gi, '')
1035
1062
  .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
1036
1063
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
1037
- return preamble + currentSection;
1064
+ return detailsSection
1065
+ ? preamble + currentSection + '\n' + detailsSection
1066
+ : preamble + currentSection;
1038
1067
  }
1039
1068
  /**
1040
1069
  * Replace a pattern only in the current milestone section of ROADMAP.md.
@@ -1399,7 +1428,12 @@ const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']);
1399
1428
  /**
1400
1429
  * Resolve the planning granularity for a phase type (#68).
1401
1430
  */
1402
- function resolveGranularityInternal(cwd, phaseType) {
1431
+ function resolveGranularityInternal(cwd, phaseType, override) {
1432
+ if (override !== undefined && override !== null && override !== '') {
1433
+ if (VALID_GRANULARITIES.has(override)) {
1434
+ return override;
1435
+ }
1436
+ }
1403
1437
  const config = loadConfig(cwd);
1404
1438
  const configGranularities = config['granularities'];
1405
1439
  const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object')
@@ -1418,6 +1452,15 @@ function resolveGranularityInternal(cwd, phaseType) {
1418
1452
  }
1419
1453
  return 'standard';
1420
1454
  }
1455
+ /**
1456
+ * Validate a CLI granularity override at the command boundary. Empty/null/undefined
1457
+ * are treated as "no override" (no-op). An invalid non-empty value calls `fail`.
1458
+ */
1459
+ function assertValidGranularityOverride(override, fail) {
1460
+ if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) {
1461
+ fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
1462
+ }
1463
+ }
1421
1464
  /**
1422
1465
  * #3024 — Resolve a model for a specific dynamic-routing attempt.
1423
1466
  */
@@ -1956,6 +1999,7 @@ module.exports = {
1956
1999
  resolveModelForTier,
1957
2000
  resolveGranularityInternal,
1958
2001
  VALID_GRANULARITIES,
2002
+ assertValidGranularityOverride,
1959
2003
  resolveEffortInternal,
1960
2004
  resolveFastModeInternal,
1961
2005
  resolveEffortForTier,
@@ -35,7 +35,7 @@ function isGraphifyEnabled(planningDir) {
35
35
  return true;
36
36
  return false;
37
37
  }
38
- catch (_e) {
38
+ catch {
39
39
  return false;
40
40
  }
41
41
  }
@@ -157,7 +157,7 @@ function safeReadJson(filePath) {
157
157
  return null;
158
158
  return JSON.parse(node_fs_1.default.readFileSync(filePath, 'utf8'));
159
159
  }
160
- catch (_e) {
160
+ catch {
161
161
  return null;
162
162
  }
163
163
  }
@@ -34,8 +34,8 @@ function routeInitCommand({ init, args, cwd, raw, error }) {
34
34
  init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
35
35
  },
36
36
  'plan-phase': () => {
37
- const namedArgs = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, [], ['validate', 'tdd']);
38
- init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
37
+ const namedArgs = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, ['granularity'], ['validate', 'tdd']);
38
+ init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'], granularity: namedArgs['granularity'] });
39
39
  },
40
40
  'new-project': () => init.cmdInitNewProject(cwd, raw),
41
41
  'new-milestone': () => init.cmdInitNewMilestone(cwd, raw),
@@ -28,7 +28,7 @@ const security_cjs_1 = require("./security.cjs");
28
28
  const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
29
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
30
30
  const frontmatterMod = require("./frontmatter.cjs");
31
- const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, gitWorktreeInfoInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, toPosixPath, output, error, checkAgentsInstalled, phaseTokenMatches, } = core;
31
+ const { loadConfig, resolveModelInternal, resolveGranularityInternal, assertValidGranularityOverride, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, gitWorktreeInfoInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, toPosixPath, output, error, checkAgentsInstalled, phaseTokenMatches, } = core;
32
32
  const { planningPaths, planningDir, planningRoot, findContextMdIn, } = planningWorkspace;
33
33
  const { determinePhaseStatus } = commandsMod;
34
34
  const { extractFrontmatter } = frontmatterMod;
@@ -297,11 +297,15 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
297
297
  expectedPhaseDirPlan = toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningPaths(cwd).phases, dirName)));
298
298
  }
299
299
  }
300
+ const granularityOverride = options['granularity'];
301
+ assertValidGranularityOverride(granularityOverride, error);
302
+ const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined);
300
303
  const result = {
301
304
  researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
302
305
  planner_model: resolveModelInternal(cwd, 'gsd-planner'),
303
306
  checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
304
307
  tdd_mode: options['tdd'] || config.tdd_mode || false,
308
+ granularity,
305
309
  research_enabled: config.research,
306
310
  plan_checker_enabled: config.plan_checker,
307
311
  nyquist_validation_enabled: config.nyquist_validation,
@@ -1521,6 +1525,11 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1521
1525
  skillPaths = [skillPaths];
1522
1526
  if (!Array.isArray(skillPaths) || skillPaths.length === 0)
1523
1527
  return '';
1528
+ // Hoist trusted roots computation before the loop: loadTrustedGlobalRoots does
1529
+ // realpathSync I/O and should run at most once per call, not once per failing skill.
1530
+ // It returns [] cheaply when no roots are configured, so the realpath cost only
1531
+ // occurs when the caller has actually set trusted_global_roots.
1532
+ const trustedGlobalRoots = (0, security_cjs_1.loadTrustedGlobalRoots)(config);
1524
1533
  const validPaths = [];
1525
1534
  for (const skillPath of skillPaths) {
1526
1535
  if (typeof skillPath !== 'string')
@@ -1548,8 +1557,15 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1548
1557
  }
1549
1558
  const pathCheck = (0, security_cjs_1.validatePath)(globalSkillMd, globalSkillsBase, { allowAbsolute: true });
1550
1559
  if (!pathCheck['safe']) {
1551
- process.stderr.write(`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`);
1552
- continue;
1560
+ const acceptedViaTrustedRoot = trustedGlobalRoots.some((root) => {
1561
+ const rootCheck = (0, security_cjs_1.validatePath)(globalSkillMd, root, { allowAbsolute: true });
1562
+ return Boolean(rootCheck['safe']);
1563
+ });
1564
+ if (!acceptedViaTrustedRoot) {
1565
+ process.stderr.write(`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`);
1566
+ continue;
1567
+ }
1568
+ process.stderr.write(`[agent-skills] NOTE: Global skill "${skillName}" accepted via trusted_global_roots (resolves outside the default skills dir)\n`);
1553
1569
  }
1554
1570
  validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
1555
1571
  continue;
@@ -59,7 +59,7 @@ function isIntelEnabled(planningDir) {
59
59
  return true;
60
60
  return false;
61
61
  }
62
- catch (_e) {
62
+ catch {
63
63
  return false;
64
64
  }
65
65
  }
@@ -86,7 +86,7 @@ function safeReadJson(filePath) {
86
86
  return null;
87
87
  return JSON.parse(raw);
88
88
  }
89
- catch (_e) {
89
+ catch {
90
90
  return null;
91
91
  }
92
92
  }
@@ -101,7 +101,7 @@ function hashFile(filePath) {
101
101
  return null;
102
102
  return node_crypto_1.default.createHash('sha256').update(content).digest('hex');
103
103
  }
104
- catch (_e) {
104
+ catch {
105
105
  return null;
106
106
  }
107
107
  }
@@ -147,23 +147,6 @@ function matchesInValue(value, lowerTerm) {
147
147
  }
148
148
  return false;
149
149
  }
150
- /**
151
- * Search for a term in arch.md text content.
152
- * Returns matching lines.
153
- */
154
- function searchArchMd(filePath, term) {
155
- try {
156
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(filePath);
157
- if (content === null)
158
- return [];
159
- const lowerTerm = term.toLowerCase();
160
- const lines = content.split(/\r?\n/);
161
- return lines.filter(line => line.toLowerCase().includes(lowerTerm));
162
- }
163
- catch (_e) {
164
- return [];
165
- }
166
- }
167
150
  /**
168
151
  * Query intel files for a search term.
169
152
  * Searches across all JSON intel files (keys and values) and arch.md (text lines).
@@ -0,0 +1,368 @@
1
+ "use strict";
2
+ /**
3
+ * Package Legitimacy Module
4
+ *
5
+ * Replaces the bolt-on prose slopcheck gate (which pip-installed `slopcheck`
6
+ * and degraded ALL packages to [ASSUMED] when pip failed) with registry-API
7
+ * verdicts computed in code.
8
+ *
9
+ * Public interface:
10
+ * DEFAULT_THRESHOLDS — baseline thresholds
11
+ * classifyPackage — pure function: signals → { verdict, reasons }
12
+ * checkPackages — async: resolves registry signals and classifies
13
+ * _setHttpGet — test seam: override the HTTP transport (pass null to restore)
14
+ *
15
+ * All network IO is injected via a `registry` client option so that tests
16
+ * never touch the real network (same seam pattern as clock injection).
17
+ *
18
+ * ADR-457 build-at-publish: authored as TypeScript .cts → emits .cjs via tsc.
19
+ */
20
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ var desc = Object.getOwnPropertyDescriptor(m, k);
23
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
+ desc = { enumerable: true, get: function() { return m[k]; } };
25
+ }
26
+ Object.defineProperty(o, k2, desc);
27
+ }) : (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ o[k2] = m[k];
30
+ }));
31
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
33
+ }) : function(o, v) {
34
+ o["default"] = v;
35
+ });
36
+ var __importStar = (this && this.__importStar) || (function () {
37
+ var ownKeys = function(o) {
38
+ ownKeys = Object.getOwnPropertyNames || function (o) {
39
+ var ar = [];
40
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
+ return ar;
42
+ };
43
+ return ownKeys(o);
44
+ };
45
+ return function (mod) {
46
+ if (mod && mod.__esModule) return mod;
47
+ var result = {};
48
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
+ __setModuleDefault(result, mod);
50
+ return result;
51
+ };
52
+ })();
53
+ const https = __importStar(require("node:https"));
54
+ // ---------------------------------------------------------------------------
55
+ // Constants
56
+ // ---------------------------------------------------------------------------
57
+ const DEFAULT_THRESHOLDS = {
58
+ minAgeDays: 30,
59
+ minWeeklyDownloads: 1000,
60
+ requireRepo: true,
61
+ };
62
+ // Matches common dangerous postinstall execution patterns.
63
+ // Deliberately EXCLUDES bare https?:// (over-fires on legit packages like
64
+ // esbuild/sharp/node-gyp that reference download URLs without executing them).
65
+ // Shell-execution / download-and-exec signatures only:
66
+ const SUSPICIOUS_POSTINSTALL_RE = /(curl |wget |\|\s*(ba)?sh|bash -c|sh -c|node -e|eval|base64 -d|\/etc\/|\.\.\/|~\/|nc |>\s*\/)/i;
67
+ // ---------------------------------------------------------------------------
68
+ // Severity ordering for verdict merging (SLOP > SUS > OK)
69
+ // ---------------------------------------------------------------------------
70
+ const SEVERITY = { OK: 0, SUS: 1, SLOP: 2 };
71
+ function moreSevereVerdict(a, b) {
72
+ return SEVERITY[a] >= SEVERITY[b] ? a : b;
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // classifyPackage — pure, no IO
76
+ // ---------------------------------------------------------------------------
77
+ function classifyPackage(signals, { thresholds = DEFAULT_THRESHOLDS, clock = Date } = {}) {
78
+ const reasons = [];
79
+ // Terminal: package does not exist
80
+ if (signals.exists === false) {
81
+ return { verdict: 'SLOP', reasons: ['does-not-exist'] };
82
+ }
83
+ // Age check
84
+ if (signals.publishedAt == null) {
85
+ reasons.push('unknown-age');
86
+ }
87
+ else {
88
+ const parsed = Date.parse(String(signals.publishedAt));
89
+ if (!Number.isFinite(parsed)) {
90
+ // Unparseable date — treat as unknown
91
+ reasons.push('unknown-age');
92
+ }
93
+ else {
94
+ const ageDays = Math.floor((clock.now() - parsed) / 86_400_000);
95
+ if (ageDays < thresholds.minAgeDays) {
96
+ reasons.push('too-new');
97
+ }
98
+ }
99
+ }
100
+ // Downloads check
101
+ const downloads = signals.weeklyDownloads;
102
+ if (downloads == null) {
103
+ reasons.push('unknown-downloads');
104
+ }
105
+ else if (typeof downloads !== 'number' || !Number.isFinite(downloads)) {
106
+ // Odd type / NaN — treat as unknown
107
+ reasons.push('unknown-downloads');
108
+ }
109
+ else if (downloads < thresholds.minWeeklyDownloads) {
110
+ reasons.push('low-downloads');
111
+ }
112
+ // Repository check
113
+ if (thresholds.requireRepo && !signals.repoUrl) {
114
+ reasons.push('no-repository');
115
+ }
116
+ // Deprecated check
117
+ if (signals.deprecated === true) {
118
+ reasons.push('deprecated');
119
+ }
120
+ // Suspicious postinstall (npm only — but apply whenever postinstall is present)
121
+ if (signals.postinstall != null && typeof signals.postinstall === 'string') {
122
+ if (SUSPICIOUS_POSTINSTALL_RE.test(signals.postinstall)) {
123
+ reasons.push('suspicious-postinstall');
124
+ }
125
+ }
126
+ // Terminal: suspicious postinstall is a slopsquatting execution risk
127
+ if (reasons.includes('suspicious-postinstall')) {
128
+ return { verdict: 'SLOP', reasons };
129
+ }
130
+ const verdict = reasons.length > 0 ? 'SUS' : 'OK';
131
+ return { verdict, reasons };
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // Injectable HTTP transport (test seam — W1)
135
+ // ---------------------------------------------------------------------------
136
+ /** The real HTTPS transport — resolves { statusCode, body } */
137
+ function realHttpsGet(url, timeoutMs) {
138
+ return new Promise((resolve, reject) => {
139
+ const req = https.get(url, { headers: { 'User-Agent': 'gsd-core-package-legitimacy/1.0' } }, (res) => {
140
+ const chunks = [];
141
+ res.on('data', (c) => chunks.push(c));
142
+ res.on('end', () => resolve({
143
+ statusCode: res.statusCode ?? 0,
144
+ body: Buffer.concat(chunks).toString('utf8'),
145
+ }));
146
+ res.on('error', reject);
147
+ });
148
+ req.setTimeout(timeoutMs, () => {
149
+ req.destroy(new Error(`timeout after ${timeoutMs}ms`));
150
+ });
151
+ req.on('error', reject);
152
+ });
153
+ }
154
+ /** Module-level transport pointer — overrideable via _setHttpGet for tests */
155
+ let httpsGet = realHttpsGet;
156
+ /**
157
+ * Test seam: replace the HTTP transport. Pass null to restore the real transport.
158
+ * Tests call this before exercising a real-adapter code path; always restore in finally.
159
+ */
160
+ function _setHttpGet(fn) {
161
+ httpsGet = fn ?? realHttpsGet;
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // Real registry adapters (not exercised by tests — tests inject fakes)
165
+ // ---------------------------------------------------------------------------
166
+ function degradedSignals() {
167
+ return {
168
+ exists: null,
169
+ publishedAt: null,
170
+ weeklyDownloads: null,
171
+ repoUrl: null,
172
+ deprecated: false,
173
+ postinstall: null,
174
+ };
175
+ }
176
+ async function lookupNpm(name, version) {
177
+ try {
178
+ const resp = await httpsGet(`https://registry.npmjs.org/${encodeURIComponent(name)}`, 5000);
179
+ if (resp.statusCode === 404)
180
+ return { ...degradedSignals(), exists: false };
181
+ if (resp.statusCode < 200 || resp.statusCode >= 300)
182
+ return degradedSignals();
183
+ const data = JSON.parse(resp.body);
184
+ if (data.error)
185
+ return { ...degradedSignals(), exists: false };
186
+ const time = data.time ?? {};
187
+ const allVersions = data.versions ?? {};
188
+ // I3: when a specific version is requested, verify it exists
189
+ if (version !== undefined) {
190
+ if (!(version in allVersions)) {
191
+ return { ...degradedSignals(), exists: false };
192
+ }
193
+ }
194
+ const latestVersion = data['dist-tags']?.latest ?? '';
195
+ const resolvedVersion = version !== undefined ? version : latestVersion;
196
+ const versionMeta = allVersions[resolvedVersion] ?? {};
197
+ const scripts = versionMeta.scripts ??
198
+ {};
199
+ const postinstall = scripts.postinstall ?? null;
200
+ const repoField = versionMeta.repository;
201
+ let repoUrl = null;
202
+ if (typeof repoField === 'string')
203
+ repoUrl = repoField;
204
+ else if (repoField && typeof repoField.url === 'string') {
205
+ repoUrl = repoField.url;
206
+ }
207
+ const deprecated = typeof versionMeta.deprecated === 'string' ? true : false;
208
+ // Fetch weekly download count from the npm downloads API
209
+ let weeklyDownloads = null;
210
+ try {
211
+ const dlResp = await httpsGet(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`, 5000);
212
+ if (dlResp.statusCode >= 200 && dlResp.statusCode < 300) {
213
+ const dlData = JSON.parse(dlResp.body);
214
+ if (typeof dlData.downloads === 'number') {
215
+ weeklyDownloads = dlData.downloads;
216
+ }
217
+ }
218
+ }
219
+ catch {
220
+ // Degraded: leave weeklyDownloads as null, never throw
221
+ }
222
+ return {
223
+ exists: true,
224
+ publishedAt: time[resolvedVersion] ?? time.created ?? null,
225
+ weeklyDownloads,
226
+ repoUrl,
227
+ deprecated,
228
+ postinstall,
229
+ ecosystem: 'npm',
230
+ };
231
+ }
232
+ catch {
233
+ return degradedSignals();
234
+ }
235
+ }
236
+ async function lookupPypi(name, version) {
237
+ try {
238
+ const resp = await httpsGet(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, 5000);
239
+ if (resp.statusCode === 404)
240
+ return { ...degradedSignals(), exists: false };
241
+ if (resp.statusCode < 200 || resp.statusCode >= 300)
242
+ return degradedSignals();
243
+ const data = JSON.parse(resp.body);
244
+ const info = data.info ?? {};
245
+ // I3: when a specific version is requested, verify it exists in releases
246
+ const releases = data.releases ?? {};
247
+ if (version !== undefined) {
248
+ if (!(version in releases)) {
249
+ return { ...degradedSignals(), exists: false };
250
+ }
251
+ }
252
+ // Finding 2: when version is provided, derive publishedAt from the
253
+ // version-specific release record rather than the package-level urls[] array
254
+ // (which reflects the latest release, not the requested version).
255
+ let uploadTime = null;
256
+ if (version !== undefined) {
257
+ const versionFiles = releases[version] ?? [];
258
+ uploadTime =
259
+ versionFiles.length > 0
260
+ ? versionFiles[0].upload_time_iso_8601 ?? null
261
+ : null;
262
+ }
263
+ else {
264
+ const urls = data.urls ?? [];
265
+ uploadTime =
266
+ urls.length > 0 ? urls[0].upload_time_iso_8601 ?? null : null;
267
+ }
268
+ const projectUrls = info.project_urls;
269
+ const repoUrl = projectUrls?.['Source'] ??
270
+ projectUrls?.['Homepage'] ??
271
+ info.home_page ??
272
+ null;
273
+ return {
274
+ exists: true,
275
+ publishedAt: uploadTime,
276
+ weeklyDownloads: null, // PyPI weekly downloads require a separate API
277
+ repoUrl: repoUrl || null,
278
+ deprecated: false, // PyPI doesn't have a first-class deprecated field
279
+ postinstall: null, // Not applicable for PyPI
280
+ ecosystem: 'pypi',
281
+ };
282
+ }
283
+ catch {
284
+ return degradedSignals();
285
+ }
286
+ }
287
+ async function lookupCrates(name, version) {
288
+ try {
289
+ const resp = await httpsGet(`https://crates.io/api/v1/crates/${encodeURIComponent(name)}`, 5000);
290
+ if (resp.statusCode === 404)
291
+ return { ...degradedSignals(), exists: false };
292
+ if (resp.statusCode < 200 || resp.statusCode >= 300)
293
+ return degradedSignals();
294
+ const data = JSON.parse(resp.body);
295
+ const krate = data.crate ?? {};
296
+ // I3: when a specific version is requested, verify it exists in versions list
297
+ const versions = data.versions ?? [];
298
+ if (version !== undefined) {
299
+ const found = versions.some((v) => v.num === version);
300
+ if (!found) {
301
+ return { ...degradedSignals(), exists: false };
302
+ }
303
+ }
304
+ const repoUrl = krate.repository ?? null;
305
+ // Finding 2: when version is provided, use the version-specific created_at
306
+ // rather than the package-level crate.created_at (first-ever publish date).
307
+ let created;
308
+ if (version !== undefined) {
309
+ const versionObj = versions.find((v) => v.num === version);
310
+ created = versionObj?.created_at ?? null;
311
+ }
312
+ else {
313
+ created = krate.created_at ?? null;
314
+ }
315
+ // recent_downloads is a 90-day count; normalize to a weekly figure for comparison
316
+ // against minWeeklyDownloads (which is a weekly threshold).
317
+ const rawDownloads = krate.recent_downloads;
318
+ const downloads = (rawDownloads != null && typeof Number(rawDownloads) === 'number' && !isNaN(Number(rawDownloads)))
319
+ ? Math.round(Number(rawDownloads) * 7 / 90)
320
+ : null;
321
+ return {
322
+ exists: true,
323
+ publishedAt: created,
324
+ weeklyDownloads: downloads,
325
+ repoUrl,
326
+ deprecated: false,
327
+ postinstall: null,
328
+ ecosystem: 'crates',
329
+ };
330
+ }
331
+ catch {
332
+ return degradedSignals();
333
+ }
334
+ }
335
+ const realRegistry = {
336
+ async lookup(ecosystem, name, version) {
337
+ switch (ecosystem) {
338
+ case 'npm':
339
+ return lookupNpm(name, version);
340
+ case 'pypi':
341
+ return lookupPypi(name, version);
342
+ case 'crates':
343
+ return lookupCrates(name, version);
344
+ default:
345
+ return degradedSignals();
346
+ }
347
+ },
348
+ };
349
+ // ---------------------------------------------------------------------------
350
+ // checkPackages — orchestrates lookup + classify + slopcheck merge
351
+ // ---------------------------------------------------------------------------
352
+ async function checkPackages({ ecosystem, packages, version }, { registry = realRegistry, clock = Date, thresholds = DEFAULT_THRESHOLDS, slopcheck = null, } = {}) {
353
+ const results = [];
354
+ for (const name of packages) {
355
+ const signals = await registry.lookup(ecosystem, name, version);
356
+ const { verdict: registryVerdict, reasons } = classifyPackage(signals, { thresholds, clock });
357
+ let finalVerdict = registryVerdict;
358
+ if (slopcheck != null) {
359
+ const slopVerdict = await slopcheck.check(ecosystem, name);
360
+ if (slopVerdict != null) {
361
+ finalVerdict = moreSevereVerdict(finalVerdict, slopVerdict);
362
+ }
363
+ }
364
+ results.push({ name, verdict: finalVerdict, signals, reasons });
365
+ }
366
+ return results;
367
+ }
368
+ module.exports = { DEFAULT_THRESHOLDS, classifyPackage, checkPackages, _setHttpGet };