@enrichlayer/el-linear 1.41.0 → 1.41.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.
@@ -9,7 +9,7 @@ import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvabl
9
9
  import { resolveDefaultStatus } from "../config/status-defaults.js";
10
10
  import { enforceTerms } from "../config/term-enforcer.js";
11
11
  import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
12
- import { DEFAULT_DUPLICATE_THRESHOLD, DEFAULT_HARD_BLOCK_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
12
+ import { bypassesDuplicateHardBlock, DEFAULT_DUPLICATE_THRESHOLD, DEFAULT_HARD_BLOCK_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
13
13
  import { createFileService } from "../utils/file-service.js";
14
14
  import { applyFooter } from "../utils/footer.js";
15
15
  import { emitGateEvent } from "../utils/gate-telemetry.js";
@@ -589,7 +589,12 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
589
589
  // The gate would hard-block. Record the decision so `el-telemetry gates`
590
590
  // can compute override-rate (DEV-4834): `overridden` when the user passed
591
591
  // --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
592
- if (options.allowDuplicate) {
592
+ //
593
+ // The escape set lives in `bypassesDuplicateHardBlock` rather than inline so
594
+ // the remedy copy in `formatDuplicateBlock` is derived from the same source
595
+ // this decision reads (DEV-6205 — the message previously named a flag the
596
+ // gate never consulted).
597
+ if (bypassesDuplicateHardBlock(options)) {
593
598
  await emitGateEvent("el-linear", "issues create", {
594
599
  ...gateEvent,
595
600
  outcome: "overridden",
@@ -73,14 +73,21 @@ export async function readIssues(issueIds, options, command) {
73
73
  const rootOpts = getRootOpts(command);
74
74
  const { graphQLService, issuesService } = await createIssuesService(rootOpts);
75
75
  const fileService = await createFileService(rootOpts);
76
- const fieldName = typeof options.field === "string" ? options.field : null;
77
- const bodyOnly = options.body === true;
78
- const sectionsRaw = typeof options.sections === "string" ? options.sections : null;
76
+ // `issues` owns these options for its no-subcommand shorthand, while
77
+ // `issues read` registers the same options on the child command. Commander
78
+ // stores a duplicated flag on the parent even when it appears after `read`,
79
+ // so the action callback's local options can be empty. Merge the command
80
+ // hierarchy here, with local values winning, so every read route shares the
81
+ // same deterministic option contract.
82
+ const readOptions = { ...command.optsWithGlobals(), ...options };
83
+ const fieldName = typeof readOptions.field === "string" ? readOptions.field : null;
84
+ const bodyOnly = readOptions.body === true;
85
+ const sectionsRaw = typeof readOptions.sections === "string" ? readOptions.sections : null;
79
86
  // DEV-4476: --with opt-in includes (currently `relations`). Throws on
80
87
  // unknown values via parseWithIncludes — fail fast in the CLI per the
81
88
  // deterministic-CLI doctrine.
82
- const includes = typeof options.with === "string"
83
- ? parseWithIncludes(options.with)
89
+ const includes = typeof readOptions.with === "string"
90
+ ? parseWithIncludes(readOptions.with)
84
91
  : { relations: false };
85
92
  if (fieldName && sectionsRaw) {
86
93
  throw new Error("--field and --sections are mutually exclusive. Use --field for a single section (plain-text output) or --sections for multiple (JSON map).");
@@ -70,6 +70,30 @@ export declare const DEFAULT_DUPLICATE_THRESHOLD = 0.35;
70
70
  * Overridable via `config.validation.duplicateHardBlockThreshold`.
71
71
  */
72
72
  export declare const DEFAULT_HARD_BLOCK_THRESHOLD = 0.6;
73
+ /**
74
+ * The one CLI flag that lets a HARD-BLOCKED create proceed.
75
+ *
76
+ * Exported so the remedy copy in {@link formatDuplicateBlock} is BUILT from the
77
+ * same constant the gate honors, rather than restating it. DEV-6205 shipped a
78
+ * remedy naming `--parent <id>` alone — a flag the gate never reads — so the
79
+ * message promised an outcome the command refused. Deriving the copy from this
80
+ * constant makes that class of drift a compile-time concern instead of a
81
+ * proofreading one.
82
+ */
83
+ export declare const DUPLICATE_GATE_OVERRIDE_FLAG = "--allow-duplicate";
84
+ /**
85
+ * Does this parsed option set clear the duplicate gate's HARD block?
86
+ *
87
+ * The single source of truth for the escape set, consumed by the gate itself
88
+ * (`enforceNoDuplicateIssue`) and asserted against the rendered remedy copy by
89
+ * the composition test. Note `--skip-validation` also bypasses, but it returns
90
+ * long before scoring (it skips ALL field validation), so it is not part of the
91
+ * hard-block decision this predicate models and is deliberately not offered as
92
+ * a remedy.
93
+ */
94
+ export declare function bypassesDuplicateHardBlock(options: {
95
+ allowDuplicate?: unknown;
96
+ }): boolean;
73
97
  /** A scored duplicate candidate, ready to print in the block. */
74
98
  export interface DuplicateCandidate {
75
99
  identifier: string;
@@ -116,5 +140,30 @@ export declare function scoreDuplicateCandidates(title: string, candidates: Line
116
140
  * "advisory"` is printed as a warning when the score is below the hard-block
117
141
  * threshold: creation already proceeded, so the trailing hint differs (no
118
142
  * "re-run" — there's nothing to re-run).
143
+ *
144
+ * Three remedies, not two (DEV-6205). "Same work → comment" and "distinct →
145
+ * --allow-duplicate" leave out the most common real case: the new work is a
146
+ * piece of the match — neither a duplicate of it nor unrelated to it. Offering
147
+ * only the two extremes pushes the operator toward reusing the matched issue,
148
+ * which is actively harmful when that issue is a multi-phase parent: the branch
149
+ * then carries the parent's id, and merging it auto-closes work that isn't done.
150
+ * That is not hypothetical — it is what the omission cost on MAR-744, where a
151
+ * findings pack was filed against a six-criterion parent because `--parent` was
152
+ * never mentioned.
153
+ *
154
+ * The sub-issue remedy is per-tier, NOT shared, because the two tiers are at
155
+ * opposite sides of the create:
156
+ *
157
+ * - **block** — creation was refused, so the remedy is a re-run. `--parent`
158
+ * alone does NOT satisfy the gate ({@link bypassesDuplicateHardBlock} reads
159
+ * only `allowDuplicate`), so the copy is built from
160
+ * {@link DUPLICATE_GATE_OVERRIDE_FLAG} and names both flags. A message that
161
+ * names a command the gate then refuses is the DEV-6205 bug one level down;
162
+ * the composition test parses this copy through the real CLI and asserts it
163
+ * actually clears the gate.
164
+ * - **advisory** — creation ALREADY proceeded, so there is nothing to re-run
165
+ * and a re-run would file a second issue (which then scores 1.0 against its
166
+ * own twin and hard-blocks). The remedy is to attach the issue that now
167
+ * exists, via `issues update`.
119
168
  */
120
169
  export declare function formatDuplicateBlock(candidates: DuplicateCandidate[], mode?: "block" | "advisory"): string;
@@ -141,6 +141,30 @@ const BOILERPLATE_STOPWORDS = new Set([
141
141
  "create",
142
142
  "update",
143
143
  ]);
144
+ /**
145
+ * The one CLI flag that lets a HARD-BLOCKED create proceed.
146
+ *
147
+ * Exported so the remedy copy in {@link formatDuplicateBlock} is BUILT from the
148
+ * same constant the gate honors, rather than restating it. DEV-6205 shipped a
149
+ * remedy naming `--parent <id>` alone — a flag the gate never reads — so the
150
+ * message promised an outcome the command refused. Deriving the copy from this
151
+ * constant makes that class of drift a compile-time concern instead of a
152
+ * proofreading one.
153
+ */
154
+ export const DUPLICATE_GATE_OVERRIDE_FLAG = "--allow-duplicate";
155
+ /**
156
+ * Does this parsed option set clear the duplicate gate's HARD block?
157
+ *
158
+ * The single source of truth for the escape set, consumed by the gate itself
159
+ * (`enforceNoDuplicateIssue`) and asserted against the rendered remedy copy by
160
+ * the composition test. Note `--skip-validation` also bypasses, but it returns
161
+ * long before scoring (it skips ALL field validation), so it is not part of the
162
+ * hard-block decision this predicate models and is deliberately not offered as
163
+ * a remedy.
164
+ */
165
+ export function bypassesDuplicateHardBlock(options) {
166
+ return Boolean(options.allowDuplicate);
167
+ }
144
168
  /**
145
169
  * Tokenize a title into a set of salient lowercase keywords.
146
170
  *
@@ -219,19 +243,52 @@ export function scoreDuplicateCandidates(title, candidates, threshold = DEFAULT_
219
243
  * "advisory"` is printed as a warning when the score is below the hard-block
220
244
  * threshold: creation already proceeded, so the trailing hint differs (no
221
245
  * "re-run" — there's nothing to re-run).
246
+ *
247
+ * Three remedies, not two (DEV-6205). "Same work → comment" and "distinct →
248
+ * --allow-duplicate" leave out the most common real case: the new work is a
249
+ * piece of the match — neither a duplicate of it nor unrelated to it. Offering
250
+ * only the two extremes pushes the operator toward reusing the matched issue,
251
+ * which is actively harmful when that issue is a multi-phase parent: the branch
252
+ * then carries the parent's id, and merging it auto-closes work that isn't done.
253
+ * That is not hypothetical — it is what the omission cost on MAR-744, where a
254
+ * findings pack was filed against a six-criterion parent because `--parent` was
255
+ * never mentioned.
256
+ *
257
+ * The sub-issue remedy is per-tier, NOT shared, because the two tiers are at
258
+ * opposite sides of the create:
259
+ *
260
+ * - **block** — creation was refused, so the remedy is a re-run. `--parent`
261
+ * alone does NOT satisfy the gate ({@link bypassesDuplicateHardBlock} reads
262
+ * only `allowDuplicate`), so the copy is built from
263
+ * {@link DUPLICATE_GATE_OVERRIDE_FLAG} and names both flags. A message that
264
+ * names a command the gate then refuses is the DEV-6205 bug one level down;
265
+ * the composition test parses this copy through the real CLI and asserts it
266
+ * actually clears the gate.
267
+ * - **advisory** — creation ALREADY proceeded, so there is nothing to re-run
268
+ * and a re-run would file a second issue (which then scores 1.0 against its
269
+ * own twin and hard-blocks). The remedy is to attach the issue that now
270
+ * exists, via `issues update`.
222
271
  */
223
272
  export function formatDuplicateBlock(candidates, mode = "block") {
224
273
  const lines = candidates.map((c) => ` ${c.identifier} · ${c.title} · ${c.state} · ${c.assignee} (similarity ${c.score})`);
274
+ // Name the parent when there is exactly one candidate; with several there is
275
+ // no single right parent, and silently picking the top score would invite a
276
+ // wrong one.
277
+ const parentRef = candidates.length === 1 ? candidates[0].identifier : "<id>";
225
278
  const header = `Possible duplicate issue${candidates.length > 1 ? "s" : ""} found ` +
226
279
  "(by title-keyword overlap):\n" +
227
280
  `${lines.join("\n")}\n\n` +
228
281
  " If one of these is the same work, comment on it instead of creating a new issue.\n";
229
282
  if (mode === "advisory") {
230
283
  return (header +
284
+ " If this is a piece of one of them rather than a duplicate, attach it with " +
285
+ `\`issues update <new-id> --parent ${parentRef}\`.\n` +
231
286
  " This is advisory only (DEV-5590) — creation is proceeding. Pass " +
232
287
  "--allow-duplicate to silence this notice next time.");
233
288
  }
234
289
  return (header +
235
- " If this is genuinely distinct, re-run with --allow-duplicate to proceed " +
290
+ " If this is a piece of one of them rather than a duplicate, re-run with " +
291
+ `--parent ${parentRef} ${DUPLICATE_GATE_OVERRIDE_FLAG} to file it as a sub-issue.\n` +
292
+ ` If this is genuinely distinct, re-run with ${DUPLICATE_GATE_OVERRIDE_FLAG} to proceed ` +
236
293
  "(and consider --related-to to link the related issue).");
237
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.41.0",
3
+ "version": "1.41.1",
4
4
  "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",