@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.
- package/agents/gsd-advisor-researcher.md +1 -20
- package/agents/gsd-ai-researcher.md +1 -20
- package/agents/gsd-domain-researcher.md +1 -20
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-phase-researcher.md +92 -166
- package/agents/gsd-planner.md +9 -36
- package/agents/gsd-project-researcher.md +62 -141
- package/agents/gsd-ui-researcher.md +2 -21
- package/agents/gsd-verifier.md +8 -2
- package/bin/install.js +85 -4
- package/commands/gsd/graphify.md +11 -6
- package/commands/gsd/import.md +6 -2
- package/commands/gsd/plan-phase.md +2 -2
- package/gsd-core/bin/check-latest-version.cjs +3 -2
- package/gsd-core/bin/gsd-tools.cjs +238 -32
- package/gsd-core/bin/lib/check-command-router.cjs +1 -0
- package/gsd-core/bin/lib/cli-exit.cjs +42 -0
- package/gsd-core/bin/lib/command-routing-hub.cjs +1 -1
- package/gsd-core/bin/lib/commands.cjs +5 -4
- package/gsd-core/bin/lib/config.cjs +28 -4
- package/gsd-core/bin/lib/core.cjs +72 -28
- package/gsd-core/bin/lib/graphify.cjs +2 -2
- package/gsd-core/bin/lib/init-command-router.cjs +2 -2
- package/gsd-core/bin/lib/init.cjs +19 -3
- package/gsd-core/bin/lib/intel.cjs +3 -20
- package/gsd-core/bin/lib/package-legitimacy.cjs +368 -0
- package/gsd-core/bin/lib/phase.cjs +3 -3
- package/gsd-core/bin/lib/research-provider.cjs +137 -0
- package/gsd-core/bin/lib/research-store.cjs +167 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +4 -19
- package/gsd-core/bin/lib/security.cjs +73 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +3 -0
- package/gsd-core/bin/lib/validate.cjs +2 -2
- package/gsd-core/bin/lib/verification-command-router.cjs +31 -0
- package/gsd-core/bin/lib/verification.cjs +193 -0
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +1 -1
- package/gsd-core/bin/lib/worktree-base-ref.cjs +325 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +31 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
- package/gsd-core/bin/verify-reapply-patches.cjs +8 -11
- package/gsd-core/references/planner-load-graph-context.md +36 -0
- package/gsd-core/references/planning-config.md +3 -1
- package/gsd-core/references/research-documentation-lookup.md +29 -0
- package/gsd-core/references/research-philosophy.md +29 -0
- package/gsd-core/references/research-verification-protocol.md +27 -0
- package/gsd-core/workflows/execute-phase.md +19 -8
- package/gsd-core/workflows/help/modes/full.md +2 -2
- package/gsd-core/workflows/ingest-docs.md +3 -2
- package/gsd-core/workflows/plan-phase.md +14 -10
- package/gsd-core/workflows/plan-review-convergence.md +3 -3
- package/gsd-core/workflows/review.md +22 -5
- package/gsd-core/workflows/ship.md +5 -8
- package/gsd-core/workflows/spec-phase.md +2 -1
- package/gsd-core/workflows/update.md +2 -1
- package/hooks/dist/gsd-context-monitor.js +1 -1
- package/hooks/dist/gsd-workflow-guard.js +1 -0
- package/hooks/dist/gsd-worktree-path-guard.js +1 -1
- package/hooks/gsd-context-monitor.js +1 -1
- package/hooks/gsd-workflow-guard.js +1 -0
- package/hooks/gsd-worktree-path-guard.js +1 -1
- package/package.json +4 -1
- package/scripts/affected-tests-lib.cjs +3 -2
- package/scripts/changeset/cli.cjs +183 -28
- package/scripts/changeset/lint.cjs +5 -4
- package/scripts/changeset/new.cjs +4 -4
- package/scripts/check-alias-drift.cjs +77 -71
- package/scripts/check-env.cjs +185 -179
- package/scripts/check-npm-integrity.cjs +115 -109
- package/scripts/ci-guard-runner.cjs +11 -5
- package/scripts/ci-prepare-test-scope.cjs +27 -22
- package/scripts/ci-rebase-check.cjs +46 -45
- package/scripts/ci-test-scope.cjs +6 -4
- package/scripts/diff-touches-shipped-paths.cjs +52 -44
- package/scripts/gen-inventory-manifest.cjs +38 -32
- package/scripts/gen-research-agents.cjs +276 -0
- package/scripts/lib/cli-exit.cjs +56 -0
- package/scripts/lint-command-contract.cjs +28 -22
- package/scripts/lint-descriptions.cjs +32 -28
- package/scripts/lint-docs-required.cjs +4 -4
- package/scripts/lint-legacy-dir-name.cjs +56 -52
- package/scripts/lint-pr-check-project-dir.cjs +3 -1
- package/scripts/lint-shell-command-projection-drift.cjs +27 -22
- package/scripts/lint-skill-deps.cjs +31 -26
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/lint-test-file-count.cjs +5 -4
- package/scripts/mutation-matrix.cjs +6 -3
- package/scripts/prompt-injection-scan.sh +1 -1
- package/scripts/release-notes/format-github-release-notes.cjs +8 -3
- package/scripts/release-tarball-smoke.cjs +6 -4
- package/scripts/research-profiles.cjs +149 -0
- package/scripts/run-affected-tests.cjs +2 -1
- package/scripts/run-cross-platform-tests.cjs +11 -7
- package/scripts/run-tests.cjs +8 -7
- package/scripts/strip-prose-atrefs.cjs +1 -1
- package/scripts/sync-runtime-launcher.cjs +0 -3
- 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
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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 (
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1552
|
-
|
|
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
|
|
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
|
|
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
|
|
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 };
|