@haystackeditor/cli 0.25.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -455
- package/dist/capture/app-config.js +25 -1
- package/dist/commands/capture-brief.js +41 -32
- package/dist/commands/feedback.js +66 -0
- package/dist/commands/init-telemetry.js +53 -7
- package/dist/commands/init.js +7 -2
- package/dist/commands/lockfile-pin.js +307 -0
- package/dist/commands/verify.js +68 -13
- package/dist/index.js +27 -923
- package/dist/schema.js +4 -10
- package/dist/utils/haystack-api.js +0 -36
- package/package.json +1 -5
- package/schemas/feedback.v1.json +13 -0
- package/schemas/pre-verify.v2.json +240 -0
- package/schemas/verify-raw.v1.json +1132 -0
- package/schemas/verify.v2.json +655 -0
- package/dist/assets/hooks/agent-context/detect.ts +0 -316
- package/dist/assets/hooks/agent-context/format.ts +0 -100
- package/dist/assets/hooks/agent-context/index.ts +0 -41
- package/dist/assets/hooks/agent-context/parsers/claude.ts +0 -262
- package/dist/assets/hooks/agent-context/parsers/codex.ts +0 -416
- package/dist/assets/hooks/agent-context/parsers/gemini.ts +0 -155
- package/dist/assets/hooks/agent-context/parsers/opencode.ts +0 -174
- package/dist/assets/hooks/agent-context/tsconfig.json +0 -14
- package/dist/assets/hooks/agent-context/types.ts +0 -58
- package/dist/assets/hooks/llm-rules-template.md +0 -59
- package/dist/assets/hooks/package-lock.json +0 -598
- package/dist/assets/hooks/package.json +0 -12
- package/dist/assets/hooks/scripts/commit-msg.sh +0 -5
- package/dist/assets/hooks/scripts/post-commit.sh +0 -5
- package/dist/assets/hooks/scripts/pre-commit.sh +0 -175
- package/dist/assets/hooks/scripts/pre-push.sh +0 -25
- package/dist/assets/hooks/scripts/prepare-commit-msg.sh +0 -5
- package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +0 -528
- package/dist/assets/hooks/truncation-checker/index.ts +0 -595
- package/dist/assets/hooks/truncation-checker/tsconfig.json +0 -13
- package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +0 -2051
- package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +0 -4
- package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +0 -3411
- package/dist/assets/skills/map-your-system.md +0 -143
- package/dist/assets/skills/submit.md +0 -200
- package/dist/commands/ask.js +0 -20
- package/dist/commands/cloud-verifier-behaviors.js +0 -218
- package/dist/commands/cloud-verifier-data-store-census.js +0 -539
- package/dist/commands/cloud-verifier-data-store-drift.js +0 -158
- package/dist/commands/cloud-verifier-identity-census.js +0 -4060
- package/dist/commands/cloud-verifier-materialization.js +0 -704
- package/dist/commands/cloud-verifier-pascal-selector-census.js +0 -1382
- package/dist/commands/cloud-verifier-python-manifest-selector-census.js +0 -2015
- package/dist/commands/cloud-verifier-specialized-operational-census.js +0 -11432
- package/dist/commands/cloud-verifier-universe.js +0 -10178
- package/dist/commands/config.js +0 -549
- package/dist/commands/design-verify.js +0 -311
- package/dist/commands/dismiss.js +0 -159
- package/dist/commands/hooks.js +0 -226
- package/dist/commands/inbox.js +0 -137
- package/dist/commands/mcp.js +0 -201
- package/dist/commands/policy.js +0 -371
- package/dist/commands/pr-status.js +0 -207
- package/dist/commands/pr.js +0 -105
- package/dist/commands/prepare-universe-review.js +0 -1092
- package/dist/commands/production-source-deny-policy.js +0 -100
- package/dist/commands/request-review.js +0 -74
- package/dist/commands/review.js +0 -191
- package/dist/commands/rules.js +0 -98
- package/dist/commands/scaffold-provisional-universe.js +0 -806
- package/dist/commands/setup.js +0 -1170
- package/dist/commands/skills.js +0 -447
- package/dist/commands/submit.js +0 -745
- package/dist/commands/system-map.js +0 -228
- package/dist/commands/triage.js +0 -598
- package/dist/commands/webhooks.js +0 -241
- package/dist/states.js +0 -46
- package/dist/tools/detect.js +0 -832
- package/dist/triage/astra.js +0 -202
- package/dist/triage/prompts.js +0 -188
- package/dist/triage/runner.js +0 -200
- package/dist/triage/types.js +0 -7
- package/dist/utils/action-output.js +0 -26
- package/dist/utils/analysis-api.js +0 -416
- package/dist/utils/design-verifier-api.js +0 -294
- package/dist/utils/design-verifier-history.js +0 -79
- package/dist/utils/design-verifier-result.js +0 -424
- package/dist/utils/github-api.js +0 -324
- package/dist/utils/pending-state.js +0 -86
- package/dist/utils/pr-ref.js +0 -56
- package/dist/utils/prompter.js +0 -328
- package/schemas/action.v1.json +0 -22
- package/schemas/ask.v1.json +0 -40
- package/schemas/inbox.v1.json +0 -27
- package/schemas/pr-status.v1.json +0 -61
- package/schemas/pr.v1.json +0 -97
- package/schemas/pr.v3.json +0 -45
- package/schemas/setup.v1.json +0 -75
- package/schemas/submit.v1.json +0 -90
- package/schemas/triage.v1.json +0 -103
- package/schemas/triage.v2.json +0 -64
|
@@ -1,42 +1,37 @@
|
|
|
1
1
|
/** CAPTURE-V1 rule 7b in `haystack pre-verify`: the routes a change touches (the brief's blast-radius files and the changed
|
|
2
2
|
* files, joined through the route manifest's source entries) with their share of captured sessions over the window, the union
|
|
3
|
-
* over all of them, freshness and drops; sessions on routes no manifest names are unmapped, never zero.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
import { join } from 'node:path';
|
|
3
|
+
* over all of them, freshness and drops; sessions on routes no manifest names are unmapped, never zero. One section per app
|
|
4
|
+
* whose `.haystack/capture.json` (rule 8b, written by `haystack init` in the app's directory, apps/web in a monorepo) records it,
|
|
5
|
+
* found as init finds them (app-config.ts); a checkout without one has no capture and the brief is exactly as before. A capture
|
|
6
|
+
* read that fails is reported in its section and never fails the command. */
|
|
7
|
+
import { join, posix } from 'node:path';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
|
+
import { CAPTURE_CONFIG_PATH, captureConfigDirs, readCaptureConfig } from '../capture/app-config.js';
|
|
9
10
|
import { classifyHttpError } from '../utils/haystack-api.js';
|
|
10
11
|
import { gatewayFetch } from './case-batch.js';
|
|
11
12
|
import { CAPTURE_APPLICATION_ID, CAPTURE_WINDOW_PATH, captureChangedRoutes, } from './capture-contract.js';
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const path = join(gitRoot, CAPTURE_IDENTITY_FILE);
|
|
17
|
-
if (!existsSync(path))
|
|
18
|
-
return null;
|
|
19
|
-
let value;
|
|
13
|
+
/** An app's application id from its record, or why the record cannot give one. */
|
|
14
|
+
function captureApplication(gitRoot, app) {
|
|
15
|
+
const file = posix.join(app, CAPTURE_CONFIG_PATH);
|
|
16
|
+
let config;
|
|
20
17
|
try {
|
|
21
|
-
|
|
18
|
+
config = readCaptureConfig(join(gitRoot, app));
|
|
22
19
|
}
|
|
23
20
|
catch (error) {
|
|
24
|
-
return { unreadable: `${
|
|
25
|
-
}
|
|
26
|
-
const applicationId = value?.applicationId;
|
|
27
|
-
if (typeof applicationId !== 'string' || !CAPTURE_APPLICATION_ID.test(applicationId)) {
|
|
28
|
-
return { unreadable: `${CAPTURE_IDENTITY_FILE} names no valid applicationId` };
|
|
21
|
+
return { unreadable: `${file}: ${error instanceof Error ? error.message : String(error)}` };
|
|
29
22
|
}
|
|
30
|
-
|
|
23
|
+
if (config === null)
|
|
24
|
+
return { unreadable: `${file} is gone` };
|
|
25
|
+
if (config.app !== app)
|
|
26
|
+
return { unreadable: `${file} records the app ${config.app}, not its own directory; run \`haystack init --app ${app}\`` };
|
|
27
|
+
if (!CAPTURE_APPLICATION_ID.test(config.applicationId))
|
|
28
|
+
return { unreadable: `${file} names no valid applicationId` };
|
|
29
|
+
return { applicationId: config.applicationId };
|
|
31
30
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
|
|
35
|
-
const application = captureApplication(gitRoot);
|
|
36
|
-
if (application === null)
|
|
37
|
-
return null;
|
|
31
|
+
async function readApp(gitRoot, app, repository, token, files, now) {
|
|
32
|
+
const application = captureApplication(gitRoot, app);
|
|
38
33
|
if ('unreadable' in application)
|
|
39
|
-
return { applicationId: null, state: 'unavailable', reason: application.unreadable };
|
|
34
|
+
return { app, applicationId: null, state: 'unavailable', reason: application.unreadable };
|
|
40
35
|
const { applicationId } = application;
|
|
41
36
|
try {
|
|
42
37
|
const response = await gatewayFetch(`${CAPTURE_WINDOW_PATH}?repository=${encodeURIComponent(repository)}&app=${encodeURIComponent(applicationId)}`, token);
|
|
@@ -46,18 +41,32 @@ export async function readPreVerifyCapture(gitRoot, repository, token, files, no
|
|
|
46
41
|
if (answer.version !== 'capture-v1' || answer.applicationId !== applicationId)
|
|
47
42
|
throw new Error('the capture window answer names another application');
|
|
48
43
|
if (answer.window === null)
|
|
49
|
-
return { applicationId, state: 'no-data' };
|
|
50
|
-
return { applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
|
|
44
|
+
return { app, applicationId, state: 'no-data' };
|
|
45
|
+
return { app, applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
|
|
51
46
|
}
|
|
52
47
|
catch (error) {
|
|
53
|
-
return { applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
|
|
48
|
+
return { app, applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
|
|
54
49
|
}
|
|
55
50
|
}
|
|
51
|
+
/** The pre-verify capture sections, one per app with a record, or null for a checkout with none. `files`: the brief's function
|
|
52
|
+
* files and the changed files. */
|
|
53
|
+
export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
|
|
54
|
+
let apps;
|
|
55
|
+
try {
|
|
56
|
+
apps = captureConfigDirs(gitRoot, []);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return [{ app: '.', applicationId: null, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) }];
|
|
60
|
+
}
|
|
61
|
+
if (apps.length === 0)
|
|
62
|
+
return null;
|
|
63
|
+
return Promise.all(apps.map(app => readApp(gitRoot, app, repository, token, files, now)));
|
|
64
|
+
}
|
|
56
65
|
const percent = (share) => share === null ? 'n/a' : `${(share * 100).toFixed(1)}%`;
|
|
57
66
|
const plain = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); // eslint-disable-line no-control-regex
|
|
58
|
-
/**
|
|
67
|
+
/** One app's block as a coding agent reads it, after the brief. */
|
|
59
68
|
export function formatCapture(capture) {
|
|
60
|
-
const lines = [chalk.bold(
|
|
69
|
+
const lines = [chalk.bold(`What real users do where your change is (captured sessions of ${capture.app === '.' ? 'the app at the repository root' : plain(capture.app)})`)];
|
|
61
70
|
if (capture.state === 'unavailable') {
|
|
62
71
|
lines.push(` Captured sessions could not be read${capture.applicationId ? ` for ${plain(capture.applicationId)}` : ''}: ${plain(capture.reason)}`);
|
|
63
72
|
return lines.join('\n');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* haystack feedback <message...>
|
|
3
|
+
*
|
|
4
|
+
* Tells the Haystack team something, in a sentence: a verify that went wrong,
|
|
5
|
+
* an output that confused you, a missing option. Meant for coding agents as
|
|
6
|
+
* much as for people: when Haystack gets in the way, say so here and keep
|
|
7
|
+
* going. The message lands in the team's Slack at once, with the CLI version,
|
|
8
|
+
* the platform and the repository (when run inside a GitHub checkout).
|
|
9
|
+
*
|
|
10
|
+
* `-` as the whole message reads it from stdin.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { withSchema } from '../schema.js';
|
|
17
|
+
import { resolveAuthContext } from '../utils/auth.js';
|
|
18
|
+
import { parseRemoteUrl } from '../utils/git.js';
|
|
19
|
+
import { haystackJson } from '../utils/haystack-api.js';
|
|
20
|
+
export const FEEDBACK_MAX_CHARS = 4000;
|
|
21
|
+
function cliVersion() {
|
|
22
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
23
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
24
|
+
}
|
|
25
|
+
/** The checkout's GitHub repository, or null outside one (feedback never needs a repository). */
|
|
26
|
+
function currentRepository() {
|
|
27
|
+
try {
|
|
28
|
+
const remote = parseRemoteUrl();
|
|
29
|
+
return `${remote.owner}/${remote.repo}`;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function buildFeedbackRequest(message, options, repository) {
|
|
36
|
+
const text = message.trim();
|
|
37
|
+
if (!text)
|
|
38
|
+
throw new Error('Say what happened: haystack feedback "<message>" (or - to read it from stdin)');
|
|
39
|
+
if (text.length > FEEDBACK_MAX_CHARS) {
|
|
40
|
+
throw new Error(`Feedback is ${text.length} characters; keep it under ${FEEDBACK_MAX_CHARS}`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
message: text,
|
|
44
|
+
context: {
|
|
45
|
+
cliVersion: cliVersion(),
|
|
46
|
+
platform: `${process.platform}-${process.arch} node ${process.versions.node}`,
|
|
47
|
+
repository,
|
|
48
|
+
runId: options.run ?? null,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export async function feedbackCommand(words, options) {
|
|
53
|
+
const message = words.length === 1 && words[0] === '-' ? readFileSync(0, 'utf8') : words.join(' ');
|
|
54
|
+
const request = buildFeedbackRequest(message, options, currentRepository());
|
|
55
|
+
const { token } = await resolveAuthContext({ preferredLogin: options.account });
|
|
56
|
+
await haystackJson('/api/cli/feedback', token, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify(request),
|
|
60
|
+
});
|
|
61
|
+
if (options.json) {
|
|
62
|
+
process.stdout.write(JSON.stringify(withSchema('feedback', { status: 'sent' }), null, 2) + '\n');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
console.log(chalk.green('Sent to the Haystack team.'));
|
|
66
|
+
}
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
* - the report (8e). InitTelemetryReport, plus the person's steps beside it: the ingest token (init never mints, prints,
|
|
21
21
|
* stores or reads one), production's environment, the start command, the deploy and the off switches (13c).
|
|
22
22
|
*/
|
|
23
|
-
import { spawnSync } from 'node:child_process';
|
|
24
|
-
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
23
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
24
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { createRequire } from 'node:module';
|
|
26
26
|
import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
@@ -31,8 +31,10 @@ import { gatewayFetch } from './case-batch.js';
|
|
|
31
31
|
import { CAPTURE_EVIDENCE_FRESH_HOURS, CAPTURE_STATUS_PATH, } from './capture-contract.js';
|
|
32
32
|
import { captureAnswers, planCapture } from './init-capture.js';
|
|
33
33
|
import { discoverApps } from '../capture/adapters/index.js';
|
|
34
|
+
import { CAPTURE_CONFIG_PATH, recordedApps } from '../capture/app-config.js';
|
|
34
35
|
import { loadBabel } from '../capture/js-ast.js';
|
|
35
36
|
import { lineDiff } from './init.js';
|
|
37
|
+
import { lockfileDrift, mergePnpmLockfile } from './lockfile-pin.js';
|
|
36
38
|
import { readTelemetryServerStatus } from './telemetry-token.js';
|
|
37
39
|
import { readFacts } from './verify-onboarding.js';
|
|
38
40
|
/** Lane D1: with the user's yes, init checks browser capture's registration with Haystack around the writes. */
|
|
@@ -214,15 +216,20 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
|
|
|
214
216
|
const spec = `${CLI_PACKAGE}@${version}`;
|
|
215
217
|
const dev = field === 'devDependencies';
|
|
216
218
|
const workspaceRoot = isFile(join(appDir, 'pnpm-workspace.yaml')) ? ['--workspace-root'] : [];
|
|
219
|
+
// Each package manager's add, as it changes no lockfile entry but the CLI's own (lockfile-pin.ts): npm with its tree
|
|
220
|
+
// nested under the CLI (hoisting would replace packages the app already locks), pnpm through the merge.
|
|
217
221
|
const argv = manager === null ? null : {
|
|
218
222
|
pnpm: ['pnpm', 'add', '--save-exact', dev ? '--save-dev' : '--save-prod', ...workspaceRoot, spec],
|
|
219
|
-
npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', spec],
|
|
223
|
+
npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', '--install-strategy=nested', spec],
|
|
220
224
|
yarn: ['yarn', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
|
|
221
225
|
bun: ['bun', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
|
|
222
226
|
}[manager.name];
|
|
223
227
|
const action = promoting ? `Moved from devDependencies to dependencies at ${version}` : `Pinned to ${version}`;
|
|
228
|
+
const lockfileNote = manager?.name === 'pnpm'
|
|
229
|
+
? `, keeping every other entry of pnpm's lockfile as it was (pnpm's own add re-resolves packages the CLI shares with the app; init adds only the CLI's entries, then \`pnpm install --frozen-lockfile\`)`
|
|
230
|
+
: `, which adds only the CLI's entries to ${manager?.name}'s lockfile`;
|
|
224
231
|
const reason = argv
|
|
225
|
-
? `${why} ${action} with \`${argv.join(' ')}\` in ${inside(gitRoot, appDir)}
|
|
232
|
+
? `${why} ${action} with \`${argv.join(' ')}\` in ${inside(gitRoot, appDir)}${lockfileNote}.`
|
|
226
233
|
: `${why} ${action}: a newer release never reaches your build unless you change it.`;
|
|
227
234
|
// The package manager's own word is not taken for it: the manifest must say what the integration needs.
|
|
228
235
|
const check = () => {
|
|
@@ -235,7 +242,39 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
|
|
|
235
242
|
return null;
|
|
236
243
|
};
|
|
237
244
|
const touches = manager === null ? [] : LOCKFILES.map(([lockfile]) => join(manager.root, lockfile));
|
|
238
|
-
|
|
245
|
+
const exec = manager === null || argv === null ? undefined : () => pin(manager, appDir, argv);
|
|
246
|
+
return { kind: 'change', change: change(gitRoot, pkg.path, reason, after, content, argv ? { argv, cwd: appDir, touches, check, exec } : undefined) };
|
|
247
|
+
}
|
|
248
|
+
/** The lockfile a package manager writes, in a format lockfile-pin.ts reads (bun's binary lockb aside). */
|
|
249
|
+
function textLockfile(manager) {
|
|
250
|
+
const name = { pnpm: 'pnpm-lock.yaml', npm: 'package-lock.json', yarn: 'yarn.lock', bun: 'bun.lock' }[manager.name];
|
|
251
|
+
const path = join(manager.root, name);
|
|
252
|
+
return isFile(path) ? { path, format: manager.name } : null;
|
|
253
|
+
}
|
|
254
|
+
/** Runs the add, so that the lockfile gains the CLI's entries and nothing else moves (rule 13); throws (init then restores
|
|
255
|
+
* every file) when the package manager moved anything else. */
|
|
256
|
+
function pin(manager, appDir, argv) {
|
|
257
|
+
const run = (command) => { execFileSync(command[0], command.slice(1), { cwd: appDir, stdio: ['ignore', 2, 2] }); };
|
|
258
|
+
const lockfile = textLockfile(manager);
|
|
259
|
+
const before = lockfile ? readFileSync(lockfile.path, 'utf8') : null;
|
|
260
|
+
const importer = posix.normalize(relative(manager.root, appDir).split(sep).join('/')) || '.';
|
|
261
|
+
if (manager.name === 'pnpm' && lockfile && before !== null) {
|
|
262
|
+
// pnpm resolves the CLI, init keeps the rest as it was, and the install takes exactly that lockfile (proving it).
|
|
263
|
+
run([...argv, '--lockfile-only']);
|
|
264
|
+
writeFileSync(lockfile.path, mergePnpmLockfile(before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE));
|
|
265
|
+
run(['pnpm', 'install', '--frozen-lockfile']);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
run(argv);
|
|
269
|
+
}
|
|
270
|
+
if (!lockfile || before === null)
|
|
271
|
+
return;
|
|
272
|
+
const moved = lockfileDrift(lockfile.format, before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE);
|
|
273
|
+
if (moved.length > 0) {
|
|
274
|
+
throw new Error(`\`${argv.join(' ')}\` also changed ${moved.length} lockfile ${moved.length === 1 ? 'entry' : 'entries'} the CLI does not own`
|
|
275
|
+
+ ` (${moved.slice(0, 5).join(', ')}${moved.length > 5 ? ', …' : ''}), so init put every file back. Add ${CLI_PACKAGE} at its exact version`
|
|
276
|
+
+ ` to ${inside(manager.root, appDir) || '.'} yourself, keeping the lockfile's other entries, then run \`haystack init\` again`);
|
|
277
|
+
}
|
|
239
278
|
}
|
|
240
279
|
function isNode(value) {
|
|
241
280
|
return !!value && typeof value === 'object' && typeof value.type === 'string' && typeof value.start === 'number';
|
|
@@ -819,13 +858,18 @@ export async function planTelemetry(gitRoot, repository, options) {
|
|
|
819
858
|
// One rule for which directories are apps (capture/adapters/index.ts): a web framework an adapter claims, or a package
|
|
820
859
|
// with a start script.
|
|
821
860
|
await loadBabel();
|
|
822
|
-
|
|
861
|
+
// The user's earlier answer first: the one app a .haystack/capture.json records (with its origins and consent, which
|
|
862
|
+
// captureAnswers reads from it). Several records are as ambiguous as several apps.
|
|
863
|
+
const discovered = await discoverApps(gitRoot);
|
|
864
|
+
const recorded = recordedApps(gitRoot, discovered);
|
|
865
|
+
const apps = recorded.length > 0 ? recorded : discovered;
|
|
823
866
|
if (apps.length !== 1) {
|
|
824
867
|
// Rule 8(a): init never picks among several apps, and never guesses one where it found none.
|
|
825
868
|
const ask = apps.length === 0
|
|
826
869
|
? 'init found no app here (no web framework it recognizes and no package with a start script). If the repository has one that serves production, run '
|
|
827
870
|
+ '`haystack init` again with `--app <its directory>`.'
|
|
828
|
-
: `Ask your user which of these apps serves production: ${apps.join(', ')}
|
|
871
|
+
: `Ask your user which of these apps serves production: ${apps.join(', ')}${recorded.length > 0 ? ` (each has a ${CAPTURE_CONFIG_PATH})` : ''};`
|
|
872
|
+
+ ' then run `haystack init` again with `--app <that directory>`.';
|
|
829
873
|
const questions = [ask, ...captureQuestions(options.origins, options.consent)];
|
|
830
874
|
// A notice too, so a preview (which reports no telemetry yet) already says what to ask.
|
|
831
875
|
notices.push(`Telemetry waits for your user's answers: ${questions.join(' ')}`);
|
|
@@ -834,6 +878,8 @@ export async function planTelemetry(gitRoot, repository, options) {
|
|
|
834
878
|
captureRegistration: null };
|
|
835
879
|
}
|
|
836
880
|
appDir = realpathSync(join(gitRoot, apps[0]));
|
|
881
|
+
if (recorded.length > 0)
|
|
882
|
+
notices.push(`Telemetry: the app is ${apps[0]}, as its ${CAPTURE_CONFIG_PATH} records (\`--app\` picks another).`);
|
|
837
883
|
}
|
|
838
884
|
const app = inside(gitRoot, appDir);
|
|
839
885
|
const changes = [];
|
package/dist/commands/init.js
CHANGED
|
@@ -54,9 +54,11 @@ export const AGENT_NOTE = [
|
|
|
54
54
|
'- Fix each bug it reports that your change caused, then run `haystack verify` again.',
|
|
55
55
|
'- If it says onboarding needs an answer, ask the user that question and file their choice with',
|
|
56
56
|
' `haystack verify answer <question-id> <choice>`.',
|
|
57
|
-
'- Read its short report. `--json` prints the
|
|
57
|
+
'- Read its short report. `--json` prints the same report as data; `--raw` prints the crawl\'s whole record (large), for scripts;',
|
|
58
58
|
' `haystack verify --help` explains the output and exit codes.',
|
|
59
59
|
'- If `haystack` is not installed, run each command as `npx -y @haystackeditor/cli@latest <command>`.',
|
|
60
|
+
'- If Haystack fails, confuses you or gets in your way, say so in a sentence with',
|
|
61
|
+
' `haystack feedback "<what happened>"` and keep going: the Haystack team sees it at once.',
|
|
60
62
|
NOTE_END,
|
|
61
63
|
].join('\n');
|
|
62
64
|
const EXIT_CODES = { planned: 2, 'login-required': 4, 'app-required': 5 };
|
|
@@ -310,7 +312,10 @@ function applyChange(gitRoot, planned) {
|
|
|
310
312
|
if (planned.run) {
|
|
311
313
|
const before = existsSync(planned.target) ? readFileSync(planned.target, 'utf8') : '';
|
|
312
314
|
// Its output goes to stderr: in --json mode stdout carries only the one document.
|
|
313
|
-
|
|
315
|
+
if (planned.run.exec)
|
|
316
|
+
planned.run.exec();
|
|
317
|
+
else
|
|
318
|
+
execFileSync(planned.run.argv[0], planned.run.argv.slice(1), { cwd: planned.run.cwd, stdio: ['ignore', 2, 2] });
|
|
314
319
|
const failed = planned.run.check?.() ?? null;
|
|
315
320
|
if (failed !== null)
|
|
316
321
|
throw new Error(`\`${planned.run.argv.join(' ')}\` ran, but ${failed}.`);
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adding one dependency to an app's lockfile without moving anything else (rule 13: init must never change what a build
|
|
3
|
+
* resolves beyond what it adds). Measured on real lockfiles (Rallly's pnpm workspace, and scratch workspaces whose app
|
|
4
|
+
* locks older versions of packages the CLI also uses):
|
|
5
|
+
* - yarn 1, yarn 4 and bun add only the new descriptors and entries; nothing else moves.
|
|
6
|
+
* - npm hoists the new tree and replaces hoisted packages the app already locks (ws, yaml, zod moved to the CLI's
|
|
7
|
+
* versions); with `--install-strategy=nested` it places the new tree under the dependency and moves nothing.
|
|
8
|
+
* - pnpm re-resolves the dependencies of every package the new tree shares with the old one to the newest version in the
|
|
9
|
+
* graph (47 entries in Rallly, from @smithy/* to express), and no flag stops it. So init lets pnpm resolve the new
|
|
10
|
+
* dependency (`--lockfile-only`), keeps every entry the lockfile had, adds the entries only the new dependency
|
|
11
|
+
* reaches (mergePnpmLockfile), and installs exactly that (`pnpm install --frozen-lockfile`, which also proves it).
|
|
12
|
+
* lockfileDrift checks the result for every format init can read, so a package manager that moves anything else fails the
|
|
13
|
+
* change (and init restores every file).
|
|
14
|
+
*/
|
|
15
|
+
function unquote(text) {
|
|
16
|
+
if (text.startsWith('\'') && text.endsWith('\''))
|
|
17
|
+
return text.slice(1, -1).replace(/''/g, '\'');
|
|
18
|
+
if (text.startsWith('"') && text.endsWith('"'))
|
|
19
|
+
return JSON.parse(text);
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
/** An entry's key line (` 'name@1.0.0(peer@2)':`, ` name@1.0.0: {}`) as its name. */
|
|
23
|
+
function keyOf(line) {
|
|
24
|
+
return unquote(line.trim().replace(/: \{\}$/, '').replace(/:$/, ''));
|
|
25
|
+
}
|
|
26
|
+
const ENTRY_SECTIONS = new Set(['importers', 'packages', 'snapshots']);
|
|
27
|
+
/** pnpm-lock.yaml as its top-level sections, the importers, packages and snapshots split into entries kept as written. */
|
|
28
|
+
function parseLock(text) {
|
|
29
|
+
const sections = [];
|
|
30
|
+
for (const line of text.replace(/\n$/, '').split('\n')) {
|
|
31
|
+
if (/^\S/.test(line))
|
|
32
|
+
sections.push({ header: line, entries: null, raw: [] });
|
|
33
|
+
else if (sections.length === 0)
|
|
34
|
+
throw new Error('pnpm-lock.yaml does not start with a section');
|
|
35
|
+
else
|
|
36
|
+
sections[sections.length - 1].raw.push(line);
|
|
37
|
+
}
|
|
38
|
+
for (const section of sections) {
|
|
39
|
+
if (!ENTRY_SECTIONS.has(section.header.replace(/:$/, '')))
|
|
40
|
+
continue;
|
|
41
|
+
const entries = [];
|
|
42
|
+
for (const line of section.raw) {
|
|
43
|
+
if (/^ {2}\S/.test(line))
|
|
44
|
+
entries.push({ name: keyOf(line), lines: [line] });
|
|
45
|
+
else if (line.trim() !== '') {
|
|
46
|
+
if (entries.length === 0)
|
|
47
|
+
throw new Error(`pnpm-lock.yaml's ${section.header} has a line outside any entry`);
|
|
48
|
+
entries[entries.length - 1].lines.push(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
section.entries = entries;
|
|
52
|
+
}
|
|
53
|
+
return sections;
|
|
54
|
+
}
|
|
55
|
+
function formatLock(sections) {
|
|
56
|
+
const blocks = sections.map(section => {
|
|
57
|
+
if (section.entries === null)
|
|
58
|
+
return [section.header, ...section.raw].join('\n');
|
|
59
|
+
return [section.header, '', section.entries.map(entry => entry.lines.join('\n')).join('\n\n'), ''].join('\n');
|
|
60
|
+
});
|
|
61
|
+
return `${blocks.join('\n').replace(/\n+$/, '')}\n`;
|
|
62
|
+
}
|
|
63
|
+
function section(sections, name) {
|
|
64
|
+
const found = sections.find(item => item.header === `${name}:`);
|
|
65
|
+
if (!found?.entries)
|
|
66
|
+
throw new Error(`pnpm-lock.yaml has no ${name} section`);
|
|
67
|
+
return found.entries;
|
|
68
|
+
}
|
|
69
|
+
/** A snapshot's edges: `dependencies` (required) and `optionalDependencies`, as snapshot keys. */
|
|
70
|
+
function edges(entry) {
|
|
71
|
+
const out = [];
|
|
72
|
+
let field = null;
|
|
73
|
+
for (const line of entry.lines.slice(1)) {
|
|
74
|
+
const header = /^ {4}(\w+):$/.exec(line);
|
|
75
|
+
if (header) {
|
|
76
|
+
field = header[1];
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (/^ {4}\S/.test(line)) {
|
|
80
|
+
field = null;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const dependency = /^ {6}(.+?): (.+)$/.exec(line);
|
|
84
|
+
if (!dependency || (field !== 'dependencies' && field !== 'optionalDependencies'))
|
|
85
|
+
continue;
|
|
86
|
+
const name = unquote(dependency[1]);
|
|
87
|
+
const version = unquote(dependency[2]);
|
|
88
|
+
if (version.startsWith('link:'))
|
|
89
|
+
continue;
|
|
90
|
+
// An alias resolves to another package: its value is that package's own key.
|
|
91
|
+
const key = /^(@[^/@]+\/)?[^@(]+@/.test(version) && !/^\d/.test(version) ? version : `${name}@${version}`;
|
|
92
|
+
out.push({ key, optional: field === 'optionalDependencies' });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** The snapshot key's package (`name@1.0.0(peer@2)` → `name@1.0.0`). */
|
|
97
|
+
function packageKey(key) {
|
|
98
|
+
const at = key.indexOf('@', 1);
|
|
99
|
+
const peers = key.indexOf('(', at);
|
|
100
|
+
return peers === -1 ? key : key.slice(0, peers);
|
|
101
|
+
}
|
|
102
|
+
/** An importer's dependencies by field (`dependencies:`, `devDependencies:`, …), each entry's lines as written. */
|
|
103
|
+
function importerFields(entry) {
|
|
104
|
+
const fields = [];
|
|
105
|
+
for (const line of entry.lines.slice(1)) {
|
|
106
|
+
const header = /^ {4}(\w+):$/.exec(line);
|
|
107
|
+
if (header) {
|
|
108
|
+
fields.push({ field: header[1], dependencies: [] });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const current = fields[fields.length - 1];
|
|
112
|
+
if (!current)
|
|
113
|
+
throw new Error(`pnpm-lock.yaml's importer ${entry.name} has a line outside any field`);
|
|
114
|
+
if (/^ {6}\S/.test(line))
|
|
115
|
+
current.dependencies.push({ field: current.field, name: keyOf(line.slice(4)), lines: [line] });
|
|
116
|
+
else if (current.dependencies.length > 0)
|
|
117
|
+
current.dependencies[current.dependencies.length - 1].lines.push(line);
|
|
118
|
+
else
|
|
119
|
+
throw new Error(`pnpm-lock.yaml's importer ${entry.name} has an unexpected ${current.field} line`);
|
|
120
|
+
}
|
|
121
|
+
return fields;
|
|
122
|
+
}
|
|
123
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
124
|
+
function versionOf(dependency) {
|
|
125
|
+
const line = dependency.lines.find(item => /^ {8}version: /.test(item));
|
|
126
|
+
if (!line)
|
|
127
|
+
throw new Error(`pnpm-lock.yaml lists ${dependency.name} with no version`);
|
|
128
|
+
return unquote(line.trim().slice('version: '.length));
|
|
129
|
+
}
|
|
130
|
+
/** Every snapshot key reachable from `roots` (required edges first: `required` holds those reached without an optional edge). */
|
|
131
|
+
function reach(snapshots, roots) {
|
|
132
|
+
const all = new Set();
|
|
133
|
+
const required = new Set();
|
|
134
|
+
const queue = roots.map(root => ({ ...root }));
|
|
135
|
+
while (queue.length > 0) {
|
|
136
|
+
const { key, optional } = queue.shift();
|
|
137
|
+
if (optional ? all.has(key) : required.has(key))
|
|
138
|
+
continue;
|
|
139
|
+
const entry = snapshots.get(key);
|
|
140
|
+
if (!entry)
|
|
141
|
+
throw new Error(`pnpm-lock.yaml references ${key}, which it does not list`);
|
|
142
|
+
all.add(key);
|
|
143
|
+
if (!optional)
|
|
144
|
+
required.add(key);
|
|
145
|
+
for (const edge of edges(entry))
|
|
146
|
+
queue.push({ key: edge.key, optional: optional || edge.optional });
|
|
147
|
+
}
|
|
148
|
+
return { all, required };
|
|
149
|
+
}
|
|
150
|
+
function withOptional(entry, optional) {
|
|
151
|
+
const body = entry.lines.slice(1).filter(line => line !== ' optional: true');
|
|
152
|
+
const key = entry.lines[0].replace(/: \{\}$/, ':');
|
|
153
|
+
if (optional)
|
|
154
|
+
return { name: entry.name, lines: [key, ...body, ' optional: true'] };
|
|
155
|
+
return { name: entry.name, lines: body.length === 0 ? [`${key.replace(/:$/, '')}: {}`] : [key, ...body] };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The lockfile `before` plus exactly what pnpm's own resolution (`after`, from `pnpm add --lockfile-only`) adds for
|
|
159
|
+
* `dependency` in `importer`: its importer entry, the snapshots and packages only it reaches, and the optional flag
|
|
160
|
+
* removed from snapshots it now requires; an earlier version of it and what only that reached go. Everything else stays as
|
|
161
|
+
* `before` had it, so shared packages keep their resolutions (each one satisfies its own ranges already).
|
|
162
|
+
*/
|
|
163
|
+
export function mergePnpmLockfile(before, after, importer, dependency) {
|
|
164
|
+
const old = parseLock(before);
|
|
165
|
+
const resolved = parseLock(after);
|
|
166
|
+
if (formatLock(old) !== before)
|
|
167
|
+
throw new Error('init cannot rewrite this pnpm-lock.yaml exactly as pnpm wrote it');
|
|
168
|
+
for (const lock of [old, resolved]) {
|
|
169
|
+
if (lock[0]?.header !== 'lockfileVersion: \'9.0\'')
|
|
170
|
+
throw new Error(`init merges pnpm lockfile version 9.0, and this one is ${lock[0]?.header ?? 'empty'}`);
|
|
171
|
+
}
|
|
172
|
+
const header = (lock) => lock.filter(item => item.entries === null).map(item => [item.header, ...item.raw].join('\n')).join('\n');
|
|
173
|
+
if (header(old) !== header(resolved))
|
|
174
|
+
throw new Error('pnpm changed the lockfile\'s settings while adding the dependency');
|
|
175
|
+
const oldImporter = section(old, 'importers').find(entry => entry.name === importer);
|
|
176
|
+
const newImporter = section(resolved, 'importers').find(entry => entry.name === importer);
|
|
177
|
+
if (!oldImporter || !newImporter)
|
|
178
|
+
throw new Error(`pnpm-lock.yaml has no importer ${importer}`);
|
|
179
|
+
const added = importerFields(newImporter).flatMap(item => item.dependencies).find(item => item.name === dependency);
|
|
180
|
+
if (!added)
|
|
181
|
+
throw new Error(`pnpm did not add ${dependency} to ${importer}`);
|
|
182
|
+
const earlier = importerFields(oldImporter).flatMap(item => item.dependencies).find(item => item.name === dependency) ?? null;
|
|
183
|
+
// The importer: as before, with the dependency in the field pnpm put it in (fields in pnpm's order).
|
|
184
|
+
const oldFields = importerFields(oldImporter);
|
|
185
|
+
const order = importerFields(newImporter).map(item => item.field);
|
|
186
|
+
const fields = [...new Set([...order, ...oldFields.map(item => item.field)])].map(field => {
|
|
187
|
+
const kept = (oldFields.find(item => item.field === field)?.dependencies ?? []).filter(item => item.name !== dependency);
|
|
188
|
+
return { field, dependencies: field === added.field ? [...kept, added].sort(byName) : kept };
|
|
189
|
+
}).filter(item => item.dependencies.length > 0);
|
|
190
|
+
const importerEntry = { name: importer, lines: [oldImporter.lines[0].replace(/: \{\}$/, ':'), ...fields.flatMap(item => [` ${item.field}:`, ...item.dependencies.flatMap(dep => dep.lines)])] };
|
|
191
|
+
// The graph: before's snapshots, then pnpm's for keys before did not have.
|
|
192
|
+
const oldSnapshots = new Map(section(old, 'snapshots').map(entry => [entry.name, entry]));
|
|
193
|
+
const newSnapshots = new Map(section(resolved, 'snapshots').map(entry => [entry.name, entry]));
|
|
194
|
+
const merged = new Map(oldSnapshots);
|
|
195
|
+
const root = `${dependency}@${versionOf(added)}`;
|
|
196
|
+
// Walk the new dependency's tree through the merged graph, taking pnpm's entries only where before had none.
|
|
197
|
+
const queue = [root];
|
|
198
|
+
const seen = new Set();
|
|
199
|
+
while (queue.length > 0) {
|
|
200
|
+
const key = queue.shift();
|
|
201
|
+
if (seen.has(key))
|
|
202
|
+
continue;
|
|
203
|
+
seen.add(key);
|
|
204
|
+
if (!merged.has(key)) {
|
|
205
|
+
const entry = newSnapshots.get(key);
|
|
206
|
+
if (!entry)
|
|
207
|
+
throw new Error(`pnpm's resolution references ${key}, which it does not list`);
|
|
208
|
+
merged.set(key, entry);
|
|
209
|
+
}
|
|
210
|
+
for (const edge of edges(merged.get(key)))
|
|
211
|
+
queue.push(edge.key);
|
|
212
|
+
}
|
|
213
|
+
const fieldOf = { dependencies: false, devDependencies: false, optionalDependencies: true };
|
|
214
|
+
const ours = reach(merged, [{ key: root, optional: fieldOf[added.field] ?? false }]);
|
|
215
|
+
for (const key of ours.all) {
|
|
216
|
+
const entry = merged.get(key);
|
|
217
|
+
const optional = !ours.required.has(key) && (oldSnapshots.has(key) ? entry.lines.includes(' optional: true') : true);
|
|
218
|
+
merged.set(key, withOptional(entry, optional));
|
|
219
|
+
}
|
|
220
|
+
// An earlier version of the dependency: what only it reached goes, as pnpm would prune it.
|
|
221
|
+
if (earlier && `${dependency}@${versionOf(earlier)}` !== root) {
|
|
222
|
+
const importers = section(old, 'importers').map(entry => (entry.name === importer ? importerEntry : entry));
|
|
223
|
+
const roots = importers.flatMap(entry => importerFields(entry).flatMap(item => item.dependencies
|
|
224
|
+
.map(dep => ({ dep, version: versionOf(dep) })).filter(({ version }) => !version.startsWith('link:'))
|
|
225
|
+
.map(({ dep, version }) => ({ key: /^(@[^/@]+\/)?[^@(]+@/.test(version) && !/^\d/.test(version) ? version : `${dep.name}@${version}`,
|
|
226
|
+
optional: fieldOf[dep.field] ?? false }))));
|
|
227
|
+
const live = reach(merged, roots).all;
|
|
228
|
+
for (const key of reach(oldSnapshots, [{ key: `${dependency}@${versionOf(earlier)}`, optional: false }]).all)
|
|
229
|
+
if (!live.has(key))
|
|
230
|
+
merged.delete(key);
|
|
231
|
+
}
|
|
232
|
+
// Packages: before's, less those only the earlier version's snapshots used, plus pnpm's for the new ones.
|
|
233
|
+
const used = new Set([...merged.keys()].map(packageKey));
|
|
234
|
+
const dropped = new Set([...oldSnapshots.keys()].filter(key => !merged.has(key)).map(packageKey).filter(name => !used.has(name)));
|
|
235
|
+
const oldPackages = section(old, 'packages');
|
|
236
|
+
const listed = new Set(oldPackages.map(entry => entry.name));
|
|
237
|
+
const newPackages = new Map(section(resolved, 'packages').map(entry => [entry.name, entry]));
|
|
238
|
+
const packages = oldPackages.filter(entry => !dropped.has(entry.name));
|
|
239
|
+
for (const name of used) {
|
|
240
|
+
if (listed.has(name))
|
|
241
|
+
continue;
|
|
242
|
+
const entry = newPackages.get(name);
|
|
243
|
+
if (!entry)
|
|
244
|
+
throw new Error(`pnpm's resolution lists no package ${name}`);
|
|
245
|
+
packages.push(entry);
|
|
246
|
+
}
|
|
247
|
+
return formatLock(old.map(item => {
|
|
248
|
+
if (item.header === 'importers:')
|
|
249
|
+
return { ...item, entries: section(old, 'importers').map(entry => (entry.name === importer ? importerEntry : entry)) };
|
|
250
|
+
if (item.header === 'packages:')
|
|
251
|
+
return { ...item, entries: packages.sort(byName) };
|
|
252
|
+
if (item.header === 'snapshots:')
|
|
253
|
+
return { ...item, entries: [...merged.values()].sort(byName) };
|
|
254
|
+
return item;
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
/** Entries as keys and their content, per format; the importer's own entry under IMPORTER. */
|
|
258
|
+
const IMPORTER = '\u0000importer';
|
|
259
|
+
function entriesOf(format, text, importer) {
|
|
260
|
+
const out = new Map();
|
|
261
|
+
if (format === 'pnpm') {
|
|
262
|
+
for (const item of parseLock(text)) {
|
|
263
|
+
if (item.entries === null) {
|
|
264
|
+
out.set(`header ${item.header}`, item.raw.join('\n'));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
for (const entry of item.entries) {
|
|
268
|
+
const name = item.header === 'importers:' && entry.name === importer ? IMPORTER : `${item.header} ${entry.name}`;
|
|
269
|
+
// The optional flag is the dependency's to clear (it now requires the package): compared without it.
|
|
270
|
+
out.set(name, entry.lines.filter(line => line !== ' optional: true').join('\n').replace(/: \{\}$/, ':'));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
else if (format === 'npm') {
|
|
275
|
+
const packages = JSON.parse(text).packages ?? {};
|
|
276
|
+
for (const [key, value] of Object.entries(packages))
|
|
277
|
+
out.set(key === (importer === '.' ? '' : importer) ? IMPORTER : key, JSON.stringify(value));
|
|
278
|
+
}
|
|
279
|
+
else if (format === 'yarn') {
|
|
280
|
+
for (const block of text.split(/\n\s*\n/)) {
|
|
281
|
+
const lines = block.split('\n').filter(line => line.trim() !== '' && !line.startsWith('#'));
|
|
282
|
+
if (lines.length === 0 || /^\s/.test(lines[0]))
|
|
283
|
+
continue;
|
|
284
|
+
for (const descriptor of lines[0].replace(/:$/, '').split(/,\s*/)) {
|
|
285
|
+
const name = unquote(descriptor);
|
|
286
|
+
out.set(/@workspace:/.test(name) && name.endsWith(`@workspace:${importer}`) ? IMPORTER : name, lines.slice(1).join('\n'));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
const parsed = JSON.parse(text.replace(/,(\s*[}\]])/g, '$1'));
|
|
292
|
+
for (const [key, value] of Object.entries(parsed.workspaces ?? {}))
|
|
293
|
+
out.set(key === (importer === '.' ? '' : importer) ? IMPORTER : `workspace ${key}`, JSON.stringify(value));
|
|
294
|
+
for (const [key, value] of Object.entries(parsed.packages ?? {}))
|
|
295
|
+
out.set(`package ${key}`, JSON.stringify(value));
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
/** The entries both lockfiles list whose content adding `dependency` to `importer` changed, other than the importer's own
|
|
300
|
+
* and the dependency's own: what that must never touch. (An entry only an earlier version of the dependency used may go;
|
|
301
|
+
* a moved resolution always shows in the entries that reference it.) */
|
|
302
|
+
export function lockfileDrift(format, before, after, importer, dependency) {
|
|
303
|
+
const a = entriesOf(format, before, importer);
|
|
304
|
+
const b = entriesOf(format, after, importer);
|
|
305
|
+
return [...a.entries()].filter(([key, value]) => key !== IMPORTER && !key.includes(dependency) && b.has(key) && b.get(key) !== value)
|
|
306
|
+
.map(([key]) => key);
|
|
307
|
+
}
|