@opengsd/gsd-core 1.3.0 → 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/installer-migrations.cjs +61 -22
- 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
|
@@ -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 };
|
|
@@ -550,7 +550,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
550
550
|
let _newPhaseId;
|
|
551
551
|
let _dirName;
|
|
552
552
|
if (customId || config.phase_naming === 'custom') {
|
|
553
|
-
_newPhaseId = customId || slug.toUpperCase()
|
|
553
|
+
_newPhaseId = customId || slug.toUpperCase();
|
|
554
554
|
if (!_newPhaseId)
|
|
555
555
|
error('--id required when phase_naming is "custom"');
|
|
556
556
|
_dirName = `${prefix}${_newPhaseId}-${slug}`;
|
|
@@ -658,7 +658,7 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
658
658
|
let newPhaseId;
|
|
659
659
|
let dirName;
|
|
660
660
|
if (config.phase_naming === 'custom') {
|
|
661
|
-
newPhaseId = slug.toUpperCase()
|
|
661
|
+
newPhaseId = slug.toUpperCase();
|
|
662
662
|
dirName = `${prefix}${newPhaseId}-${slug}`;
|
|
663
663
|
}
|
|
664
664
|
else {
|
|
@@ -913,7 +913,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
913
913
|
content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
|
|
914
914
|
content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
|
|
915
915
|
content = content.replace(/(\|\s*)(\d+)(\.\s)/g, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
|
|
916
|
-
content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)
|
|
916
|
+
content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
|
|
917
917
|
content = content.replace(/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
|
|
918
918
|
content = content.replace(/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
|
|
919
919
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Research Provider Module
|
|
4
|
+
*
|
|
5
|
+
* Encodes the Balanced-set provider decision: PROVIDER_WATERFALL constant,
|
|
6
|
+
* classifyConfidence, providerAvailability, and planResearch (with injectable
|
|
7
|
+
* store for testability).
|
|
8
|
+
*
|
|
9
|
+
* ADR-457 build-at-publish: authored as TypeScript .cts → emits .cjs via tsc.
|
|
10
|
+
*/
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Cycle 1 / Cycle 6: PROVIDER_WATERFALL (Balanced-set decision)
|
|
13
|
+
// firecrawl appears ONLY in scrape — demoted to known-URL scrape, NOT in docs/web
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const PROVIDER_WATERFALL = {
|
|
16
|
+
docs: ['context7', 'ref', 'jina', 'websearch'],
|
|
17
|
+
web: ['exa', 'tavily', 'perplexity', 'brave', 'websearch'],
|
|
18
|
+
scrape: ['firecrawl', 'jina'],
|
|
19
|
+
};
|
|
20
|
+
function authorityOf(provider) {
|
|
21
|
+
switch (provider) {
|
|
22
|
+
case 'context7':
|
|
23
|
+
case 'ref':
|
|
24
|
+
return 'official';
|
|
25
|
+
case 'jina':
|
|
26
|
+
case 'firecrawl':
|
|
27
|
+
return 'scrape';
|
|
28
|
+
case 'exa':
|
|
29
|
+
case 'tavily':
|
|
30
|
+
case 'perplexity':
|
|
31
|
+
case 'brave':
|
|
32
|
+
case 'websearch':
|
|
33
|
+
return 'web';
|
|
34
|
+
default:
|
|
35
|
+
return 'none';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function normalizeLegitimacyVerdict(raw) {
|
|
39
|
+
if (typeof raw !== 'string')
|
|
40
|
+
return null;
|
|
41
|
+
const upper = raw.toUpperCase();
|
|
42
|
+
if (upper === 'OK' || upper === 'SUS' || upper === 'SLOP')
|
|
43
|
+
return upper;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function classifyConfidence(input) {
|
|
47
|
+
try {
|
|
48
|
+
const { provider, verifiedAgainstOfficial, legitimacyVerdict } = input;
|
|
49
|
+
const authority = authorityOf(provider);
|
|
50
|
+
const verdict = normalizeLegitimacyVerdict(legitimacyVerdict);
|
|
51
|
+
const groundTruth = verdict === 'OK';
|
|
52
|
+
// SLOP caps everything — checked first
|
|
53
|
+
if (verdict === 'SLOP')
|
|
54
|
+
return 'LOW';
|
|
55
|
+
// Ground-truth corroboration + known authority → HIGH
|
|
56
|
+
if (groundTruth && authority !== 'none')
|
|
57
|
+
return 'HIGH';
|
|
58
|
+
// Official or scrape provider (authority alone) → MEDIUM
|
|
59
|
+
if (authority === 'official' || authority === 'scrape')
|
|
60
|
+
return 'MEDIUM';
|
|
61
|
+
// Ground-truth but unknown provider → MEDIUM
|
|
62
|
+
if (groundTruth)
|
|
63
|
+
return 'MEDIUM';
|
|
64
|
+
// Web provider with self-reported verification → MEDIUM
|
|
65
|
+
if (authority === 'web' && verifiedAgainstOfficial === true)
|
|
66
|
+
return 'MEDIUM';
|
|
67
|
+
return 'LOW';
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return 'LOW';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Cycle 5: providerAvailability
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
function providerAvailability(config) {
|
|
77
|
+
const cfg = config ?? {};
|
|
78
|
+
return {
|
|
79
|
+
context7: true,
|
|
80
|
+
jina: cfg.jina !== undefined ? Boolean(cfg.jina) : true,
|
|
81
|
+
websearch: true,
|
|
82
|
+
exa: Boolean(cfg.exa_search),
|
|
83
|
+
tavily: Boolean(cfg.tavily_search),
|
|
84
|
+
brave: Boolean(cfg.brave_search),
|
|
85
|
+
firecrawl: Boolean(cfg.firecrawl),
|
|
86
|
+
ref: Boolean(cfg.ref_search),
|
|
87
|
+
perplexity: Boolean(cfg.perplexity),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Lazy-load default store (avoids circular require at module eval time)
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
let _defaultStore;
|
|
94
|
+
function getDefaultStore() {
|
|
95
|
+
if (!_defaultStore) {
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy default store; tests inject their own
|
|
97
|
+
_defaultStore = require('./research-store.cjs');
|
|
98
|
+
}
|
|
99
|
+
return _defaultStore;
|
|
100
|
+
}
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Cycle 1–3, 5, 7: planResearch
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
function planResearch(options) {
|
|
105
|
+
const { questions, ecosystem = '', cwd, config, clock = Date, homeDir, store = getDefaultStore(), } = options;
|
|
106
|
+
const availability = providerAvailability(config);
|
|
107
|
+
const items = questions.flatMap((q) => {
|
|
108
|
+
const { text, kind, library, version } = q;
|
|
109
|
+
// Skip questions without a non-empty string text — emitting an item with
|
|
110
|
+
// question:undefined / fetch.query:undefined would produce corrupt output.
|
|
111
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const key = store.researchKey({ ecosystem, library, version, query: text, kind });
|
|
115
|
+
const res = store.getResearch(cwd, key, { clock, homeDir, kind });
|
|
116
|
+
// Fresh cache hit — no fetch needed
|
|
117
|
+
if (res.hit && !res.stale) {
|
|
118
|
+
return { question: text, key, cache: { hit: true, stale: false } };
|
|
119
|
+
}
|
|
120
|
+
// Determine which waterfall to use
|
|
121
|
+
const waterfall = PROVIDER_WATERFALL[kind] ?? PROVIDER_WATERFALL.web;
|
|
122
|
+
// Pick first available provider
|
|
123
|
+
const provider = waterfall.find((p) => availability[p] === true) ?? 'websearch';
|
|
124
|
+
const item = {
|
|
125
|
+
question: text,
|
|
126
|
+
key,
|
|
127
|
+
fetch: { provider, query: text },
|
|
128
|
+
};
|
|
129
|
+
// Stale hit: include cache info
|
|
130
|
+
if (res.hit) {
|
|
131
|
+
item.cache = { hit: true, stale: true };
|
|
132
|
+
}
|
|
133
|
+
return item;
|
|
134
|
+
});
|
|
135
|
+
return { items };
|
|
136
|
+
}
|
|
137
|
+
module.exports = { PROVIDER_WATERFALL, classifyConfidence, providerAvailability, planResearch };
|