@fission-ai/openspec 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +33 -14
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.js +8 -0
  13. package/dist/commands/workflow/status.d.ts +5 -1
  14. package/dist/commands/workflow/status.js +42 -5
  15. package/dist/core/archive.js +17 -1
  16. package/dist/core/artifact-graph/schema.d.ts +14 -0
  17. package/dist/core/artifact-graph/schema.js +52 -0
  18. package/dist/core/artifact-graph/types.js +12 -1
  19. package/dist/core/change-status-policy.d.ts +14 -0
  20. package/dist/core/change-status-policy.js +14 -6
  21. package/dist/core/command-generation/generator.js +2 -0
  22. package/dist/core/completion-tip.js +7 -7
  23. package/dist/core/completions/installers/bash-installer.js +15 -7
  24. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  25. package/dist/core/completions/installers/shell-quote.js +13 -0
  26. package/dist/core/completions/installers/zsh-installer.js +3 -2
  27. package/dist/core/config-schema.d.ts +1 -1
  28. package/dist/core/config.d.ts +16 -0
  29. package/dist/core/config.js +27 -1
  30. package/dist/core/global-config.d.ts +30 -2
  31. package/dist/core/global-config.js +57 -3
  32. package/dist/core/init.js +13 -3
  33. package/dist/core/legacy-cleanup.d.ts +4 -0
  34. package/dist/core/legacy-cleanup.js +180 -12
  35. package/dist/core/list.js +55 -11
  36. package/dist/core/migration.js +6 -1
  37. package/dist/core/parsers/change-parser.d.ts +12 -1
  38. package/dist/core/parsers/change-parser.js +85 -87
  39. package/dist/core/parsers/markdown-parser.js +4 -3
  40. package/dist/core/parsers/requirement-blocks.d.ts +44 -3
  41. package/dist/core/parsers/requirement-blocks.js +90 -15
  42. package/dist/core/parsers/requirement-text.d.ts +13 -2
  43. package/dist/core/parsers/requirement-text.js +34 -7
  44. package/dist/core/parsers/spec-structure.js +4 -1
  45. package/dist/core/project-config.d.ts +9 -1
  46. package/dist/core/project-config.js +11 -2
  47. package/dist/core/references.d.ts +20 -0
  48. package/dist/core/references.js +56 -0
  49. package/dist/core/shared/skill-content-equivalence.js +5 -1
  50. package/dist/core/shared/skill-generation.js +36 -8
  51. package/dist/core/shared/tool-detection.js +9 -3
  52. package/dist/core/specs-apply.js +82 -11
  53. package/dist/core/store/git.d.ts +33 -0
  54. package/dist/core/store/git.js +60 -6
  55. package/dist/core/store/operations.d.ts +1 -1
  56. package/dist/core/store/operations.js +32 -10
  57. package/dist/core/store/registry.d.ts +2 -1
  58. package/dist/core/store/registry.js +5 -1
  59. package/dist/core/templates/optional-workflow.d.ts +69 -0
  60. package/dist/core/templates/optional-workflow.js +140 -0
  61. package/dist/core/templates/workflows/apply-change.js +18 -3
  62. package/dist/core/templates/workflows/archive-change.js +50 -15
  63. package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
  64. package/dist/core/templates/workflows/continue-change.js +14 -2
  65. package/dist/core/templates/workflows/explore.js +32 -14
  66. package/dist/core/templates/workflows/ff-change.js +19 -3
  67. package/dist/core/templates/workflows/new-change.js +15 -3
  68. package/dist/core/templates/workflows/onboard.js +67 -37
  69. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  70. package/dist/core/templates/workflows/project-root.js +38 -0
  71. package/dist/core/templates/workflows/propose.js +22 -5
  72. package/dist/core/templates/workflows/sync-specs.js +40 -1
  73. package/dist/core/templates/workflows/update-change.js +52 -25
  74. package/dist/core/templates/workflows/verify-change.js +12 -3
  75. package/dist/core/update.d.ts +12 -0
  76. package/dist/core/update.js +58 -3
  77. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  78. package/dist/core/validation/task-checkboxes.js +181 -0
  79. package/dist/core/validation/validator.d.ts +53 -1
  80. package/dist/core/validation/validator.js +154 -40
  81. package/dist/core/version-check.d.ts +14 -7
  82. package/dist/core/version-check.js +63 -17
  83. package/dist/core/worksets.js +6 -4
  84. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  85. package/dist/prompts/searchable-multi-select.js +35 -6
  86. package/dist/telemetry/config.js +14 -2
  87. package/dist/telemetry/index.d.ts +4 -3
  88. package/dist/telemetry/index.js +22 -10
  89. package/dist/telemetry/opt-out.d.ts +17 -0
  90. package/dist/telemetry/opt-out.js +27 -0
  91. package/dist/utils/nested-change.d.ts +35 -0
  92. package/dist/utils/nested-change.js +186 -0
  93. package/dist/utils/spec-discovery.d.ts +20 -0
  94. package/dist/utils/spec-discovery.js +59 -0
  95. package/dist/utils/task-progress.d.ts +1 -1
  96. package/dist/utils/task-progress.js +38 -6
  97. package/package.json +4 -4
  98. package/schemas/spec-driven/schema.yaml +10 -3
  99. package/schemas/spec-driven/templates/design.md +2 -0
  100. package/schemas/spec-driven/templates/proposal.md +2 -0
  101. package/schemas/spec-driven/templates/spec.md +2 -0
  102. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -6,13 +6,14 @@ import { ChangeParser } from '../parsers/change-parser.js';
6
6
  import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
7
7
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
8
8
  import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, findMissingCurrentScenarios, } from '../parsers/requirement-blocks.js';
9
- import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, } from '../parsers/requirement-text.js';
9
+ import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
10
10
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
12
- import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
12
+ import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
13
13
  import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
14
14
  import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
15
15
  import { findTaskNumberingIssues } from './task-numbering.js';
16
+ import { findMissingTaskCheckboxIssues } from './task-checkboxes.js';
16
17
  import { findPurposePlaceholderIssue } from './purpose-placeholder.js';
17
18
  import { getPackageSchemasDir, getSchemaDir } from '../artifact-graph/index.js';
18
19
  export class Validator {
@@ -174,6 +175,34 @@ export class Validator {
174
175
  : `Header "### ${stray.header}" in ${stray.section} is not a "### Requirement:" header and is ignored by validation. Use "### Requirement: ${stray.header}" if it should be validated as a requirement.`,
175
176
  });
176
177
  }
178
+ // A FROM:/TO: line that never formed a pair. Reported here so the author
179
+ // learns at authoring time, rather than having archive either skip the
180
+ // rename or apply it to a requirement they never named.
181
+ for (const unpaired of plan.unpairedRenames) {
182
+ const missing = unpaired.side === 'FROM' ? 'TO' : 'FROM';
183
+ issues.push({
184
+ level: 'ERROR',
185
+ path: entryPath,
186
+ line: unpaired.line,
187
+ message: `RENAMED ${unpaired.side}: "${unpaired.name}" has no matching ${missing}: line. Write each rename as a FROM: line followed immediately by its TO: line.`,
188
+ });
189
+ }
190
+ // A well-formed requirement written outside every delta section. The
191
+ // reader only looks inside the four delta sections, so this block is
192
+ // ignored - reported as a WARNING rather than an ERROR because a
193
+ // handful of pre-format archived changes still carry this shape, and
194
+ // the fix is to move the block, not to reject the change outright.
195
+ for (const orphan of plan.orphanedRequirements) {
196
+ const where = orphan.section
197
+ ? `under "## ${orphan.section}"`
198
+ : 'above the first "## " section';
199
+ issues.push({
200
+ level: 'WARNING',
201
+ path: entryPath,
202
+ line: orphan.line,
203
+ message: `Requirement "${orphan.name}" is ${where}, which is not a delta section, so it is ignored. Move it under "## ADDED Requirements", "## MODIFIED Requirements", "## REMOVED Requirements", or "## RENAMED Requirements".`,
204
+ });
205
+ }
177
206
  const sectionNames = [];
178
207
  if (plan.sectionPresence.added)
179
208
  sectionNames.push('## ADDED Requirements');
@@ -225,7 +254,7 @@ export class Validator {
225
254
  }
226
255
  const scenarioCount = this.countScenarios(block.raw);
227
256
  if (scenarioCount < 1) {
228
- issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario` });
257
+ issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
229
258
  }
230
259
  }
231
260
  // Validate MODIFIED
@@ -257,7 +286,7 @@ export class Validator {
257
286
  }
258
287
  const scenarioCount = this.countScenarios(block.raw);
259
288
  if (scenarioCount < 1) {
260
- issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario` });
289
+ issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
261
290
  }
262
291
  }
263
292
  // Run archive's scenario-loss check here too, so the change fails at
@@ -358,6 +387,17 @@ export class Validator {
358
387
  throw error;
359
388
  }
360
389
  }
390
+ // The same drop happens to delta sections in any other file the merge
391
+ // path does not read (specs/<capability>.md, a note beside spec.md),
392
+ // while the artifact graph's specs/**/*.md glob counts it as written.
393
+ const unreadDeltaFiles = await findUnreadDeltaFiles(specsDir);
394
+ for (const file of unreadDeltaFiles) {
395
+ issues.push({
396
+ level: 'ERROR',
397
+ path: file.path,
398
+ message: `Delta spec found at specs/${file.path}. Delta specs must be a spec.md inside a capability folder — this file is ignored when the change is applied or archived. Move its requirements into specs/${file.expected}.`,
399
+ });
400
+ }
361
401
  for (const { path: specPath, sections } of emptySectionSpecs) {
362
402
  issues.push({
363
403
  level: 'ERROR',
@@ -397,10 +437,10 @@ export class Validator {
397
437
  if (skipSpecs && specsDirHasFiles) {
398
438
  issues.push({ level: 'ERROR', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT });
399
439
  }
400
- // The root-level error already names the file and the fix; adding "No
401
- // deltas found" on top would contradict it, since the deltas are sitting in
402
- // the file just reported.
403
- if (totalDeltas === 0 && !hasRootLevelSpec) {
440
+ // The root-level and unread-file errors already name the file and the fix;
441
+ // adding "No deltas found" on top would contradict them, since the deltas
442
+ // are sitting in the files just reported.
443
+ if (totalDeltas === 0 && !hasRootLevelSpec && unreadDeltaFiles.length === 0) {
404
444
  if (skipSpecs && !specsDirHasFiles) {
405
445
  issues.push({ level: 'INFO', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_ACCEPTED });
406
446
  }
@@ -409,56 +449,120 @@ export class Validator {
409
449
  }
410
450
  }
411
451
  if (options.projectRoot) {
412
- issues.push(...await this.collectTaskNumberingIssues(changeDir, options.projectRoot));
452
+ issues.push(...await this.collectTaskFileIssues(changeDir, options.projectRoot));
413
453
  }
414
454
  return this.createReport(issues);
415
455
  }
416
- async collectTaskNumberingIssues(changeDir, projectRoot) {
456
+ /**
457
+ * Lints the change's task files.
458
+ *
459
+ * Two checks with deliberately different reach. Checkbox formatting is read
460
+ * from whatever the change's own schema declares as its tracked task output,
461
+ * because every schema's progress, apply and archive behavior is computed by
462
+ * counting checkboxes in exactly those files. Numbering stays scoped to the
463
+ * built-in `spec-driven` schema, whose template is the one that numbers tasks
464
+ * in the first place.
465
+ *
466
+ * The checkbox check never falls back to a bare top-level `tasks.md`: a file
467
+ * no artifact declares is not a tracked task list, and warning about its
468
+ * formatting would be a guess about a file the tool does not read.
469
+ */
470
+ async collectTaskFileIssues(changeDir, projectRoot) {
471
+ let trackedFiles;
417
472
  try {
418
- const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot).replace(/\.ya?ml$/, '');
419
- const schemaDir = getSchemaDir(schemaName, projectRoot);
420
- const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven');
421
- if (schemaName !== 'spec-driven' ||
422
- schemaDir === null ||
423
- FileSystemUtils.canonicalizeExistingPath(schemaDir) !==
424
- FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir)) {
425
- return [];
426
- }
473
+ trackedFiles = resolveTaskFilesForChange(changeDir, projectRoot);
427
474
  }
428
475
  catch {
429
476
  return [];
430
477
  }
431
- let taskFiles;
432
- try {
433
- taskFiles = resolveTaskFilesForChange(changeDir, projectRoot);
434
- }
435
- catch {
436
- return [];
478
+ const files = trackedFiles.length > 0 ? trackedFiles : [path.join(changeDir, 'tasks.md')];
479
+ const { documents, unreadable } = await this.readTaskDocuments(changeDir, files);
480
+ const toWarning = (issue) => ({
481
+ level: 'WARNING',
482
+ path: issue.path,
483
+ line: issue.line,
484
+ message: issue.message,
485
+ });
486
+ const issues = [];
487
+ // "No file here holds a checkbox" is a claim about the whole tracked set, so
488
+ // a file that exists but could not be read withdraws it: the checkboxes may
489
+ // be in exactly that file. `validate --archived` is the surface that reports
490
+ // an unreadable task file loudly (#205); this one must not guess from it.
491
+ if (trackedFiles.length > 0 && unreadable === 0) {
492
+ issues.push(...findMissingTaskCheckboxIssues(documents).map(toWarning));
437
493
  }
438
- if (taskFiles.length === 0) {
439
- taskFiles = [path.join(changeDir, 'tasks.md')];
494
+ if (this.usesBuiltInSpecDrivenSchema(changeDir, projectRoot)) {
495
+ issues.push(...findTaskNumberingIssues(documents).map(toWarning));
440
496
  }
497
+ return issues;
498
+ }
499
+ /**
500
+ * Reads task files into change-relative documents, counting the ones that
501
+ * exist but could not be read. A file that is simply absent is not counted:
502
+ * the resolver only returns files it found, so the remaining read failures
503
+ * are permissions and I/O, and a check that reasons over the whole set needs
504
+ * to know its evidence was incomplete.
505
+ */
506
+ async readTaskDocuments(changeDir, files) {
441
507
  const documents = [];
442
- for (const taskFile of taskFiles) {
508
+ let unreadable = 0;
509
+ for (const file of files) {
443
510
  let content;
444
511
  try {
445
- content = await fs.readFile(taskFile, 'utf-8');
512
+ content = await fs.readFile(file, 'utf-8');
446
513
  }
447
- catch {
514
+ catch (error) {
515
+ if (error?.code !== 'ENOENT')
516
+ unreadable++;
448
517
  continue;
449
518
  }
450
- documents.push({
451
- path: FileSystemUtils.toPosixPath(path.relative(changeDir, taskFile)),
452
- content,
453
- });
519
+ documents.push({ path: this.taskDocumentPath(changeDir, file), content });
454
520
  }
455
521
  documents.sort((left, right) => left.path.localeCompare(right.path));
456
- return findTaskNumberingIssues(documents).map((issue) => ({
457
- level: 'WARNING',
458
- path: issue.path,
459
- line: issue.line,
460
- message: issue.message,
461
- }));
522
+ return { documents, unreadable };
523
+ }
524
+ /**
525
+ * Names a task file relative to its change, POSIX-separated.
526
+ *
527
+ * Both sides are canonicalized first. `resolveArtifactOutputs` hands back real
528
+ * paths, while `changeDir` carries whatever spelling the caller resolved, and
529
+ * the two can differ without being apart: on Windows a short 8.3 alias
530
+ * (`RUNNER~1`) against its expanded form turned `tasks.md` into a
531
+ * `../../../..`-prefixed absolute path in the report, and a symlinked project
532
+ * directory does the same elsewhere. Canonicalizing recovers the real
533
+ * relationship. The Windows CI job is the regression guard — the mismatch
534
+ * cannot be staged on POSIX, where the spawned CLI's `process.cwd()` is
535
+ * already physical.
536
+ *
537
+ * A path that still escapes would be a resolver bug rather than a spelling
538
+ * difference, but the report must never leak an absolute filesystem path, so
539
+ * the file name stands in.
540
+ */
541
+ taskDocumentPath(changeDir, file) {
542
+ const relative = path.relative(FileSystemUtils.canonicalizeExistingPath(changeDir), FileSystemUtils.canonicalizeExistingPath(file));
543
+ const escapes = relative === '' || relative.startsWith('..') || path.isAbsolute(relative);
544
+ return escapes ? path.basename(file) : FileSystemUtils.toPosixPath(relative);
545
+ }
546
+ /**
547
+ * True when the change resolves to the package's own `spec-driven` schema. A
548
+ * project schema that merely reuses the name is not it, so checks written
549
+ * against the built-in template never fire on someone else's task format.
550
+ */
551
+ usesBuiltInSpecDrivenSchema(changeDir, projectRoot) {
552
+ try {
553
+ const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot).replace(/\.ya?ml$/, '');
554
+ if (schemaName !== 'spec-driven')
555
+ return false;
556
+ const schemaDir = getSchemaDir(schemaName, projectRoot);
557
+ if (schemaDir === null)
558
+ return false;
559
+ const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven');
560
+ return (FileSystemUtils.canonicalizeExistingPath(schemaDir) ===
561
+ FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir));
562
+ }
563
+ catch {
564
+ return false;
565
+ }
462
566
  }
463
567
  /**
464
568
  * Report MODIFIED requirements whose block omits a scenario the main spec
@@ -797,6 +901,16 @@ export class Validator {
797
901
  // example is not a real scenario. Drop the header line (index 0).
798
902
  return countScenariosShared(blockRaw.split('\n').slice(1));
799
903
  }
904
+ /**
905
+ * Why a requirement with a visible scenario header still has no scenario:
906
+ * the header has no body, and the spec path archive validates against does
907
+ * not count it. Empty when the block has no bare scenario header.
908
+ */
909
+ emptyScenarioHint(blockRaw) {
910
+ if (countEmptyScenarios(blockRaw.split('\n').slice(1)) === 0)
911
+ return '';
912
+ return ' (a scenario header with no body under it does not count; add its steps, e.g. "- **WHEN** ..." and "- **THEN** ...")';
913
+ }
800
914
  formatSectionList(sections) {
801
915
  if (sections.length === 0)
802
916
  return '';
@@ -1,12 +1,14 @@
1
1
  /**
2
- * The registry to ask: only the environment variable npm exports (under
3
- * `npm run`, or an explicit export). Deliberately not a `registry=` line from
4
- * any .npmrc — letting file contents choose the destination of an outbound
5
- * request is a flow worth avoiding for a convenience this small, and a project
6
- * file would travel with a cloned repository. Anyone on a private mirror can
7
- * export `npm_config_registry`, or turn the check off entirely.
2
+ * The registry to ask. A configured registry is honored only over TLS: a
3
+ * cleartext hop would aim the request at a link-local or internal address and
4
+ * let anyone on the path choose the "newer version" answer.
5
+ *
6
+ * A rejected registry disables the check rather than falling back to public
7
+ * npm. Falling back would send a request an organization on an internal mirror
8
+ * deliberately avoided, and would then report a version resolved against a
9
+ * registry the eventual `npm install -g` does not use.
8
10
  */
9
- export declare function registryUrl(): string;
11
+ export declare function registryUrl(): string | null;
10
12
  /**
11
13
  * Compares two semver-ish versions. Returns 1 when a > b, -1 when a < b, 0
12
14
  * otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
@@ -92,6 +94,11 @@ export declare function buildUpgradeCommandLines(installDir: string | null, proj
92
94
  * that may not be the one on PATH, a project dependency belongs to that
93
95
  * project's package manager, an npx/dlx cache has nothing to upgrade, and a
94
96
  * source checkout is not an install at all.
97
+ *
98
+ * A non-default registry is also disqualifying. `npm install -g` resolves
99
+ * against whatever `npm_config_registry` says, which npm may have read from a
100
+ * cloned repository's .npmrc — so such a registry may still inform the check,
101
+ * but must never be the thing that offers to install a global package.
95
102
  */
96
103
  export declare function canSelfUpgrade(installDir: string | null, projectPath: string): boolean;
97
104
  /**
@@ -5,7 +5,8 @@ import path from 'path';
5
5
  import { createRequire } from 'module';
6
6
  import chalk from 'chalk';
7
7
  import { isCiEnvironment } from '../utils/ci.js';
8
- import { getGlobalConfig } from './global-config.js';
8
+ import { isTelemetryOptedOutByEnv } from '../telemetry/opt-out.js';
9
+ import { getGlobalConfig, isGlobalConfigUnreadable } from './global-config.js';
9
10
  const require = createRequire(import.meta.url);
10
11
  const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
11
12
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
@@ -29,9 +30,7 @@ const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\
29
30
  function isCheckEnabled() {
30
31
  if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined)
31
32
  return false;
32
- if (process.env.DO_NOT_TRACK === '1')
33
- return false;
34
- if (process.env.OPENSPEC_TELEMETRY === '0')
33
+ if (isTelemetryOptedOutByEnv())
35
34
  return false;
36
35
  if (isCiEnvironment())
37
36
  return false;
@@ -40,21 +39,53 @@ function isCheckEnabled() {
40
39
  // Same config opt-out as telemetry (env remains the hard override above).
41
40
  if (getGlobalConfig().telemetry?.enabled === false)
42
41
  return false;
42
+ // A config that cannot be parsed may be hiding that opt-out.
43
+ if (isGlobalConfigUnreadable())
44
+ return false;
43
45
  return true;
44
46
  }
45
47
  /**
46
- * The registry to ask: only the environment variable npm exports (under
47
- * `npm run`, or an explicit export). Deliberately not a `registry=` line from
48
- * any .npmrc — letting file contents choose the destination of an outbound
49
- * request is a flow worth avoiding for a convenience this small, and a project
50
- * file would travel with a cloned repository. Anyone on a private mirror can
51
- * export `npm_config_registry`, or turn the check off entirely.
48
+ * The configured registry, if npm exported one. Not trustworthy: npm exports
49
+ * every config source it read, including a `registry=` line in a
50
+ * repository-local .npmrc that travels with a clone.
52
51
  */
53
- export function registryUrl() {
52
+ function configuredRegistry() {
54
53
  const configured = process.env.npm_config_registry?.trim();
55
- const base = configured && /^https?:\/\//i.test(configured) ? configured : DEFAULT_REGISTRY;
54
+ return configured ? configured : undefined;
55
+ }
56
+ /**
57
+ * The registry to ask. A configured registry is honored only over TLS: a
58
+ * cleartext hop would aim the request at a link-local or internal address and
59
+ * let anyone on the path choose the "newer version" answer.
60
+ *
61
+ * A rejected registry disables the check rather than falling back to public
62
+ * npm. Falling back would send a request an organization on an internal mirror
63
+ * deliberately avoided, and would then report a version resolved against a
64
+ * registry the eventual `npm install -g` does not use.
65
+ */
66
+ export function registryUrl() {
67
+ const configured = configuredRegistry();
68
+ if (configured && !/^https:\/\//i.test(configured))
69
+ return null;
70
+ const base = configured ?? DEFAULT_REGISTRY;
56
71
  return `${base.replace(/\/+$/, '')}/${PACKAGE_NAME}/latest`;
57
72
  }
73
+ /**
74
+ * True when the check is talking to the public npm registry. Read from the raw
75
+ * env var, not from registryUrl(), so a registry that was rejected above still
76
+ * disqualifies a self-upgrade instead of looking like the default.
77
+ */
78
+ function isDefaultRegistry() {
79
+ const configured = configuredRegistry();
80
+ if (!configured)
81
+ return true;
82
+ try {
83
+ return new URL(configured).origin === new URL(DEFAULT_REGISTRY).origin;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
58
89
  /**
59
90
  * Compares two prerelease tags per SemVer: dot-separated identifiers compared
60
91
  * one by one, numeric identifiers numerically (so beta.10 > beta.2), numeric
@@ -141,7 +172,12 @@ function fetchLatestVersion() {
141
172
  };
142
173
  let url;
143
174
  try {
144
- url = new URL(registryUrl());
175
+ const resolved = registryUrl();
176
+ if (resolved === null) {
177
+ resolve(null);
178
+ return;
179
+ }
180
+ url = new URL(resolved);
145
181
  }
146
182
  catch {
147
183
  resolve(null);
@@ -169,10 +205,13 @@ function fetchLatestVersion() {
169
205
  redirectsLeft -= 1;
170
206
  try {
171
207
  const next = new URL(location, target);
172
- // Never follow a downgrade to plain http: a MITM on the reply
173
- // would control the "newer version" answer.
174
- const downgrade = target.protocol === 'https:' && next.protocol === 'http:';
175
- if (!downgrade && (next.protocol === 'http:' || next.protocol === 'https:')) {
208
+ // Cross-host is allowed - that is the whole point of following a
209
+ // redirect for a mirror or corporate front-end - but never off
210
+ // TLS: a MITM on a cleartext hop would control the "newer
211
+ // version" answer, and the reply cannot install anything on its
212
+ // own (canSelfUpgrade refuses a non-default registry, and the
213
+ // version is re-validated against a strict pattern).
214
+ if (next.protocol === 'https:') {
176
215
  send(next);
177
216
  return;
178
217
  }
@@ -492,10 +531,17 @@ function loadSpawn() {
492
531
  * that may not be the one on PATH, a project dependency belongs to that
493
532
  * project's package manager, an npx/dlx cache has nothing to upgrade, and a
494
533
  * source checkout is not an install at all.
534
+ *
535
+ * A non-default registry is also disqualifying. `npm install -g` resolves
536
+ * against whatever `npm_config_registry` says, which npm may have read from a
537
+ * cloned repository's .npmrc — so such a registry may still inform the check,
538
+ * but must never be the thing that offers to install a global package.
495
539
  */
496
540
  export function canSelfUpgrade(installDir, projectPath) {
497
541
  if (!installDir)
498
542
  return false;
543
+ if (!isDefaultRegistry())
544
+ return false;
499
545
  if (isEphemeralRunnerInstall(installDir))
500
546
  return false;
501
547
  // Both anchors matter: `openspec update ../other` from a project that owns
@@ -175,7 +175,7 @@ export function worksetNotFoundError(name, state) {
175
175
  });
176
176
  }
177
177
  export function withWorkset(state, workset) {
178
- if (state.worksets[workset.name] !== undefined) {
178
+ if (Object.prototype.hasOwnProperty.call(state.worksets, workset.name)) {
179
179
  throw new StoreError(`Workset '${workset.name}' already exists.`, 'workset_exists', {
180
180
  target: 'workset.name',
181
181
  fix: `Choose another name, or remove it first: openspec workset remove ${workset.name}`,
@@ -193,7 +193,7 @@ export function withWorkset(state, workset) {
193
193
  };
194
194
  }
195
195
  export function withoutWorkset(state, name) {
196
- if (state.worksets[name] === undefined) {
196
+ if (!Object.prototype.hasOwnProperty.call(state.worksets, name)) {
197
197
  throw worksetNotFoundError(name, state);
198
198
  }
199
199
  const remaining = { ...state.worksets };
@@ -226,8 +226,10 @@ export function listWorksets(state) {
226
226
  .sort((a, b) => a.name.localeCompare(b.name));
227
227
  }
228
228
  export function getWorkset(state, name) {
229
- const entry = state.worksets[name];
230
- return entry === undefined ? null : toWorkset(name, entry);
229
+ if (!Object.prototype.hasOwnProperty.call(state.worksets, name)) {
230
+ return null;
231
+ }
232
+ return toWorkset(name, state.worksets[name]);
231
233
  }
232
234
  /**
233
235
  * The generated .code-workspace content: members in saved order with
@@ -1,6 +1,11 @@
1
1
  interface Choice {
2
2
  name: string;
3
3
  value: string;
4
+ /**
5
+ * Extra terms the search box matches, for choices users look for by a word
6
+ * the name does not spell (see #653). Never rendered.
7
+ */
8
+ searchAliases?: string[];
4
9
  description?: string;
5
10
  configured?: boolean;
6
11
  detected?: boolean;
@@ -12,6 +17,8 @@ interface Config {
12
17
  choices: Choice[];
13
18
  pageSize?: number;
14
19
  validate?: (selected: string[]) => boolean | string;
20
+ /** Shown when a search matches nothing, so the list is not a dead end. */
21
+ emptyHint?: string;
15
22
  }
16
23
  /**
17
24
  * A searchable multi-select prompt with visible search box,
@@ -1,4 +1,11 @@
1
1
  import chalk from 'chalk';
2
+ /**
3
+ * True when every character is printable, so the text can go in the search box.
4
+ * `\u007f` is DEL, which sorts above the printable range.
5
+ */
6
+ function isPrintable(text) {
7
+ return text.length > 0 && [...text].every((char) => char >= ' ' && char !== '\u007f');
8
+ }
2
9
  /**
3
10
  * Create the searchable multi-select prompt.
4
11
  * Uses dynamic import to prevent pre-commit hook hangs (see #367).
@@ -6,7 +13,7 @@ import chalk from 'chalk';
6
13
  async function createSearchableMultiSelect() {
7
14
  const { createPrompt, useState, useKeypress, useMemo, usePrefix, isEnterKey, isBackspaceKey, isUpKey, isDownKey, } = await import('@inquirer/core');
8
15
  return createPrompt((config, done) => {
9
- const { message, choices, pageSize = 15, validate } = config;
16
+ const { message, choices, pageSize = 15, validate, emptyHint } = config;
10
17
  const [searchText, setSearchText] = useState('');
11
18
  const [selectedValues, setSelectedValues] = useState(() => choices.filter(c => c.preSelected).map(c => c.value));
12
19
  const [cursor, setCursor] = useState(0);
@@ -17,9 +24,10 @@ async function createSearchableMultiSelect() {
17
24
  const filteredChoices = useMemo(() => {
18
25
  if (!searchText.trim())
19
26
  return choices;
20
- const term = searchText.toLowerCase();
27
+ const term = searchText.trim().toLowerCase();
21
28
  return choices.filter((c) => c.name.toLowerCase().includes(term) ||
22
- c.value.toLowerCase().includes(term));
29
+ c.value.toLowerCase().includes(term) ||
30
+ (c.searchAliases ?? []).some((alias) => alias.toLowerCase().includes(term)));
23
31
  }, [searchText, choices]);
24
32
  const selectedSet = useMemo(() => new Set(selectedValues), [selectedValues]);
25
33
  const choiceMap = useMemo(() => new Map(choices.map((c) => [c.value, c])), [choices]);
@@ -72,9 +80,28 @@ async function createSearchableMultiSelect() {
72
80
  setCursor(Math.min(filteredChoices.length - 1, cursor + 1));
73
81
  return;
74
82
  }
75
- // Character input - handle printable characters
76
- if (key.name && key.name.length === 1 && !key.ctrl) {
77
- setSearchText(searchText + key.name);
83
+ // Character input - handle printable characters.
84
+ // readline reports punctuation (`.`, `-`, `/`) only in `sequence`, leaving
85
+ // `name` undefined, so keying off `name` alone silently dropped every
86
+ // non-alphanumeric character the user typed.
87
+ // `@inquirer/core` types only `name` and `ctrl`; readline emits more.
88
+ const event = key;
89
+ if (event.ctrl || event.meta)
90
+ return;
91
+ // Requiring every character to be printable drops escape sequences
92
+ // (arrows, function keys), tab, escape and delete. readline splits a
93
+ // paste into one key per character, so a pasted space still toggles.
94
+ const typed = typeof event.sequence === 'string' && isPrintable(event.sequence)
95
+ ? event.sequence
96
+ : // Only the sequence may be multi-character: readline `name`s such as
97
+ // 'tab' and 'escape' are printable strings but not typed input.
98
+ typeof event.name === 'string' &&
99
+ event.name.length === 1 &&
100
+ isPrintable(event.name)
101
+ ? event.name
102
+ : undefined;
103
+ if (typed) {
104
+ setSearchText(searchText + typed);
78
105
  setCursor(0);
79
106
  }
80
107
  });
@@ -102,6 +129,8 @@ async function createSearchableMultiSelect() {
102
129
  // List
103
130
  if (filteredChoices.length === 0) {
104
131
  lines.push(chalk.yellow(' No matches'));
132
+ if (emptyHint)
133
+ lines.push(chalk.dim(` ${emptyHint}`));
105
134
  }
106
135
  else {
107
136
  // Calculate pagination
@@ -5,7 +5,7 @@
5
5
  import { promises as fs } from 'fs';
6
6
  import path from 'path';
7
7
  import os from 'os';
8
- import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, } from '../core/global-config.js';
8
+ import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, isConfigRootObject, unreadableGlobalConfigMessage, } from '../core/global-config.js';
9
9
  // Constants
10
10
  export const CONFIG_DIR_NAME = GLOBAL_CONFIG_DIR_NAME;
11
11
  export const CONFIG_FILE_NAME = GLOBAL_CONFIG_FILE_NAME;
@@ -18,7 +18,14 @@ function getLegacyConfigPath() {
18
18
  async function readConfigFile(configPath) {
19
19
  try {
20
20
  const content = await fs.readFile(configPath, 'utf-8');
21
- return { status: 'ok', config: JSON.parse(content) };
21
+ const parsed = JSON.parse(content);
22
+ // Valid JSON that is not an object carries no settings to merge into, and
23
+ // spreading it would replace the file (a string even spreads to numeric
24
+ // character keys). Same predicate the rest of the CLI refuses to save over.
25
+ if (!isConfigRootObject(parsed)) {
26
+ return { status: 'invalid', config: {} };
27
+ }
28
+ return { status: 'ok', config: parsed };
22
29
  }
23
30
  catch (error) {
24
31
  if (error.code === 'ENOENT') {
@@ -108,6 +115,11 @@ export async function readConfig() {
108
115
  */
109
116
  export async function writeConfig(updates) {
110
117
  const configPath = getConfigPath();
118
+ // Never write over a file that did not parse: the merge below would start
119
+ // from an empty object and replace every setting in it with these updates.
120
+ if ((await readConfigFile(configPath)).status === 'invalid') {
121
+ throw new Error(unreadableGlobalConfigMessage(configPath));
122
+ }
111
123
  // Read existing config and merge
112
124
  const existing = await readConfig();
113
125
  const merged = { ...existing, ...updates };
@@ -2,11 +2,12 @@
2
2
  * Check if telemetry is enabled.
3
3
  *
4
4
  * Precedence (first match wins):
5
- * 1. OPENSPEC_TELEMETRY=0 → disabled
6
- * 2. DO_NOT_TRACK=1 → disabled
5
+ * 1. OPENSPEC_TELEMETRY set to anything but an on-value (1/true/yes/on) → disabled
6
+ * 2. DO_NOT_TRACK set to anything but an off-value (0/false/no/off) → disabled
7
7
  * 3. CI set to a truthy/on value → disabled (same rule as version-check)
8
8
  * 4. global config telemetry.enabled === false → disabled
9
- * 5. otherwise enabled (unset config means on; opt-out model)
9
+ * 5. global config file exists but cannot be parsed → disabled
10
+ * 6. otherwise enabled (unset config means on; opt-out model)
10
11
  *
11
12
  * Kept synchronous so call sites need not become async. Reads config via
12
13
  * sync getGlobalConfig() rather than async getTelemetryConfig().