@enrichlayer/el-linear 1.15.0 → 1.17.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.
@@ -196,7 +196,28 @@ async function handleUpdateComment(commentId, options, command) {
196
196
  else {
197
197
  input.body = body;
198
198
  }
199
- const result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, { id: commentId, input });
199
+ let result;
200
+ try {
201
+ result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, { id: commentId, input });
202
+ }
203
+ catch (err) {
204
+ // Same defense-in-depth fallback as `handleCreateComment` above: when
205
+ // Linear rejects `bodyData` (schema drift, unsupported node, validator
206
+ // quirk), retry the mutation with raw markdown `body` so the update
207
+ // goes through even if our prosemirror converter has fallen behind a
208
+ // schema change. The asymmetry between create and update used to mean
209
+ // any future drift would silently break updates while creates kept
210
+ // working — DEV-4261 closes that gap. Two call sites is the floor for
211
+ // extraction; copy-then-refactor on the third per the repo convention.
212
+ const msg = err instanceof Error ? err.message : String(err);
213
+ if (input.bodyData && BODY_DATA_ERROR_RE.test(msg)) {
214
+ const fallbackInput = { body };
215
+ result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, { id: commentId, input: fallbackInput });
216
+ }
217
+ else {
218
+ throw err;
219
+ }
220
+ }
200
221
  const mutation = result.commentUpdate;
201
222
  if (!mutation.success || !mutation.comment) {
202
223
  throw new Error("Failed to update comment");
@@ -34,7 +34,7 @@ async function handleCreateDocument(options, command) {
34
34
  title: options.title,
35
35
  content: options.content,
36
36
  projectId: options.project
37
- ? await linearService.resolveProjectId(options.project)
37
+ ? await linearService.resolveProjectId(options.project, options.team)
38
38
  : undefined,
39
39
  teamId: options.team
40
40
  ? await linearService.resolveTeamId(options.team)
@@ -292,6 +292,10 @@ async function resolveCreateInputs(title, options, rootOpts) {
292
292
  title,
293
293
  assignee: effectiveAssignee,
294
294
  project: options.project,
295
+ // DEV-4084: scope the accepted type-label set to the team so the
296
+ // DEV team's `research` label validates without `--skip-validation`.
297
+ // Falls back to the workspace default when no team is provided.
298
+ team: options.team || config.defaultTeam || undefined,
295
299
  });
296
300
  // Apply normalized labels back so resolution uses the canonical names
297
301
  if (validationResult.normalizedLabels) {
@@ -47,6 +47,14 @@ export interface ElLinearConfig {
47
47
  validation?: {
48
48
  enabled: boolean;
49
49
  typeLabels?: string[];
50
+ /**
51
+ * Per-team overrides of the canonical type-label set. Keys are Linear
52
+ * team keys (uppercase, e.g. `DEV`). When set, an `issues create
53
+ * --team X` call validates labels against the team-scoped set rather
54
+ * than the workspace default — see `issue-validation.ts` for the
55
+ * built-in overrides (DEV-4084).
56
+ */
57
+ teamTypeLabels?: Record<string, string[]>;
50
58
  };
51
59
  /**
52
60
  * Optional override for the Linear workspace URL key (the part after
@@ -232,8 +232,13 @@ export async function enrichValidationErrors(result, options, services) {
232
232
  const projects = teamData.projects?.nodes ?? [];
233
233
  const members = teamData.members?.nodes ?? [];
234
234
  const labels = teamData.labels?.nodes ?? [];
235
- const typeLabels = getCanonicalTypeLabels();
236
- const inferred = options.title ? inferTypeFromTitle(options.title) : null;
235
+ // DEV-4084: scope the suggested type-label set to the team when one is
236
+ // known so the retry hint surfaces (e.g.) `research` instead of `spike`
237
+ // on the Dev team.
238
+ const typeLabels = getCanonicalTypeLabels(options.team);
239
+ const inferred = options.title
240
+ ? inferTypeFromTitle(options.title, options.team)
241
+ : null;
237
242
  for (let i = 0; i < result.errors.length; i++) {
238
243
  result.errors[i] = decorateError(result.errors[i], classifications[i], {
239
244
  projects,
@@ -13,6 +13,14 @@
13
13
  *
14
14
  * Exported so error-enrichment can reuse the same mapping when inferring a
15
15
  * type label from a title's first word.
16
+ *
17
+ * **Ambiguous-verb resolution.** When a verb belongs to multiple type sets
18
+ * (e.g. `Research` appears under both `spike` and `research`), the first
19
+ * declared type wins — `Object.entries` preserves insertion order, and
20
+ * `inferTypeFromTitle` filters by the team's accepted set before iterating.
21
+ * In practice no team accepts both `spike` and `research` (the synonyms),
22
+ * so the first-declared-wins rule never bites; if a future team accepts
23
+ * both, list the preferred one earlier in this map.
16
24
  */
17
25
  export declare const TYPE_VERB_MAP: Record<string, string[]>;
18
26
  export interface ValidationResult {
@@ -27,12 +35,23 @@ export interface ValidationInput {
27
35
  title: string;
28
36
  assignee: string | undefined;
29
37
  project: string | undefined;
38
+ /**
39
+ * Team key (e.g. `DEV`) the issue is being created on. When set, the
40
+ * validator consults the team-scoped `typeLabels` override (DEV-4084) so
41
+ * a team whose taxonomy doesn't include the workspace default (`spike`)
42
+ * can validate its own equivalent (`research`) without `--skip-validation`.
43
+ *
44
+ * `team` is otherwise advisory: it does not affect description/title/
45
+ * assignee/project requirements.
46
+ */
47
+ team?: string;
30
48
  }
31
49
  /**
32
- * Public accessor for the canonical type labels.
33
- * Used by error-enrichment when suggesting labels.
50
+ * Public accessor for the canonical type labels. Pass the team key to get
51
+ * the team-scoped set (DEV-4084); omit for the workspace default. Used by
52
+ * error-enrichment when suggesting labels.
34
53
  */
35
- export declare function getCanonicalTypeLabels(): string[];
54
+ export declare function getCanonicalTypeLabels(team?: string): string[];
36
55
  /**
37
56
  * Find the canonical type label for a title's first word, if it matches a
38
57
  * known verb in TYPE_VERB_MAP. Returns the matched verb and inferred type,
@@ -53,7 +72,7 @@ export declare function getCanonicalTypeLabels(): string[];
53
72
  * conflate "warn about mismatch" with "suggest a default" and produce
54
73
  * subtle bugs (e.g. inferring a type the alignment check just rejected).
55
74
  */
56
- export declare function inferTypeFromTitle(title: string): {
75
+ export declare function inferTypeFromTitle(title: string, team?: string): {
57
76
  verb: string;
58
77
  type: string;
59
78
  } | null;
@@ -11,12 +11,54 @@ import { outputWarning } from "../utils/output.js";
11
11
  import { loadConfig } from "./config.js";
12
12
  /** Canonical type labels. Stored in config so they can be updated without code changes. */
13
13
  const DEFAULT_TYPE_LABELS = ["bug", "feature", "refactor", "chore", "spike"];
14
+ /**
15
+ * Built-in per-team overrides for the canonical type-label set. Teams whose
16
+ * taxonomy diverges from the workspace default ship here so the CLI accepts
17
+ * their labels out of the box without forcing every operator to maintain a
18
+ * personal config override.
19
+ *
20
+ * DEV-4084: the Dev team uses `research` instead of `spike` (and has no
21
+ * `spike` label) — the DEV-3768 fix removed the silent `research → spike`
22
+ * alias correctly, but left the validator's accepted set workspace-wide,
23
+ * so DEV creators were forced into `--skip-validation` (which also drops
24
+ * title-verb, label-count, and description checks).
25
+ *
26
+ * This widens the accepted set for DEV (no rewrite — DEV-3768's no-silent-
27
+ * alias guarantee is preserved) so `--labels "research,tools"` validates
28
+ * cleanly on DEV while every other team keeps `spike`.
29
+ */
30
+ const TEAM_TYPE_LABELS = {
31
+ DEV: ["bug", "feature", "refactor", "chore", "research"],
32
+ };
33
+ /**
34
+ * Verbs that indicate a `spike`-equivalent type. Shared between `spike` and
35
+ * its team-local synonym `research` so adding a new investigation verb only
36
+ * has to land in one place — without this constant the two arrays drift the
37
+ * next time someone adds e.g. `Probe`.
38
+ */
39
+ const SPIKE_VERBS = [
40
+ "Research",
41
+ "Investigate",
42
+ "Explore",
43
+ "Evaluate",
44
+ "Audit",
45
+ "Benchmark",
46
+ "Test",
47
+ ];
14
48
  /**
15
49
  * Recommended leading verbs for each type label.
16
50
  * Title verb and type label should express the same intent.
17
51
  *
18
52
  * Exported so error-enrichment can reuse the same mapping when inferring a
19
53
  * type label from a title's first word.
54
+ *
55
+ * **Ambiguous-verb resolution.** When a verb belongs to multiple type sets
56
+ * (e.g. `Research` appears under both `spike` and `research`), the first
57
+ * declared type wins — `Object.entries` preserves insertion order, and
58
+ * `inferTypeFromTitle` filters by the team's accepted set before iterating.
59
+ * In practice no team accepts both `spike` and `research` (the synonyms),
60
+ * so the first-declared-wins rule never bites; if a future team accepts
61
+ * both, list the preferred one earlier in this map.
20
62
  */
21
63
  export const TYPE_VERB_MAP = {
22
64
  bug: ["Fix", "Resolve", "Patch", "Handle", "Address", "Correct"],
@@ -56,15 +98,13 @@ export const TYPE_VERB_MAP = {
56
98
  "Teardown",
57
99
  "Upgrade",
58
100
  ],
59
- spike: [
60
- "Research",
61
- "Investigate",
62
- "Explore",
63
- "Evaluate",
64
- "Audit",
65
- "Benchmark",
66
- "Test",
67
- ],
101
+ spike: SPIKE_VERBS,
102
+ // Team-local synonym for `spike`. Active when the validator is scoped to
103
+ // a team whose `typeLabels` includes `research` (see `TEAM_TYPE_LABELS` /
104
+ // `validation.teamTypeLabels`). Shares `SPIKE_VERBS` so the two stay in
105
+ // lockstep — drift would silently produce different inferences depending
106
+ // on which team you're on.
107
+ research: SPIKE_VERBS,
68
108
  refactor: [
69
109
  "Refactor",
70
110
  "Restructure",
@@ -91,17 +131,50 @@ const LABEL_ALIASES = {
91
131
  function getValidationConfig() {
92
132
  const config = loadConfig();
93
133
  const validation = config.validation;
134
+ // User-supplied `teamTypeLabels` are layered on top of the built-in
135
+ // `TEAM_TYPE_LABELS` so an operator can add a new team's override
136
+ // without having to re-declare DEV's. A user override for an existing
137
+ // team key replaces the built-in entry for that key.
138
+ //
139
+ // Normalize user-config keys to uppercase before merging — Linear team
140
+ // keys are uppercase canonical, but an operator who writes
141
+ // `{ "dev": [...] }` (lowercase) should still hit DEV's override. Doing
142
+ // it here means `resolveTypeLabels` can rely on a uniformly uppercased
143
+ // map without re-walking on every lookup.
144
+ const userOverrides = {};
145
+ for (const [key, value] of Object.entries(validation?.teamTypeLabels ?? {})) {
146
+ userOverrides[key.toUpperCase()] = value;
147
+ }
148
+ const teamTypeLabels = {
149
+ ...TEAM_TYPE_LABELS,
150
+ ...userOverrides,
151
+ };
94
152
  return {
95
153
  enabled: validation?.enabled ?? true,
96
154
  typeLabels: validation?.typeLabels ?? DEFAULT_TYPE_LABELS,
155
+ teamTypeLabels,
97
156
  };
98
157
  }
99
158
  /**
100
- * Public accessor for the canonical type labels.
101
- * Used by error-enrichment when suggesting labels.
159
+ * Resolve the canonical type-label set for a team. DEV-4084: when the team
160
+ * has a per-team override (built-in or config-declared) that set is returned;
161
+ * otherwise the workspace default. The team key is normalized to uppercase
162
+ * because Linear team keys are case-insensitive but stored uppercase.
163
+ */
164
+ function resolveTypeLabels(team) {
165
+ const cfg = getValidationConfig();
166
+ if (!team)
167
+ return cfg.typeLabels;
168
+ const teamKey = team.toUpperCase();
169
+ return cfg.teamTypeLabels[teamKey] ?? cfg.typeLabels;
170
+ }
171
+ /**
172
+ * Public accessor for the canonical type labels. Pass the team key to get
173
+ * the team-scoped set (DEV-4084); omit for the workspace default. Used by
174
+ * error-enrichment when suggesting labels.
102
175
  */
103
- export function getCanonicalTypeLabels() {
104
- return getValidationConfig().typeLabels;
176
+ export function getCanonicalTypeLabels(team) {
177
+ return resolveTypeLabels(team);
105
178
  }
106
179
  /**
107
180
  * Find the canonical type label for a title's first word, if it matches a
@@ -123,10 +196,18 @@ export function getCanonicalTypeLabels() {
123
196
  * conflate "warn about mismatch" with "suggest a default" and produce
124
197
  * subtle bugs (e.g. inferring a type the alignment check just rejected).
125
198
  */
126
- export function inferTypeFromTitle(title) {
199
+ export function inferTypeFromTitle(title, team) {
127
200
  const lowered = title.toLowerCase();
201
+ // DEV-4084: when scoping to a team, only suggest a type that the team
202
+ // actually accepts. This makes "Research GraphQL caching options" on a
203
+ // DEV team infer `research` instead of `spike` (which DEV doesn't have)
204
+ // without disrupting other teams whose taxonomy still uses `spike`.
205
+ const allowedTypes = new Set(resolveTypeLabels(team));
206
+ const isAllowed = (type) => allowedTypes.has(type);
128
207
  // Multi-word verbs first ("Set up")
129
208
  for (const [type, verbs] of Object.entries(TYPE_VERB_MAP)) {
209
+ if (!isAllowed(type))
210
+ continue;
130
211
  for (const verb of verbs) {
131
212
  if (!verb.includes(" ")) {
132
213
  continue;
@@ -144,6 +225,8 @@ export function inferTypeFromTitle(title) {
144
225
  }
145
226
  const firstLower = firstWord.toLowerCase();
146
227
  for (const [type, verbs] of Object.entries(TYPE_VERB_MAP)) {
228
+ if (!isAllowed(type))
229
+ continue;
147
230
  for (const verb of verbs) {
148
231
  if (verb.includes(" ")) {
149
232
  continue;
@@ -182,6 +265,10 @@ export function validateIssueCreation(input) {
182
265
  if (!vConfig.enabled) {
183
266
  return result;
184
267
  }
268
+ // DEV-4084: scope the accepted type-label set to the team when one was
269
+ // passed. Falls back to the workspace default when no team is supplied
270
+ // or no override exists.
271
+ const typeLabels = resolveTypeLabels(input.team);
185
272
  // --- Label normalization (always runs when validation is on) ---
186
273
  if (input.labels && input.labels.length > 0) {
187
274
  result.normalizedLabels = input.labels.map((label) => {
@@ -196,21 +283,21 @@ export function validateIssueCreation(input) {
196
283
  // --- Required: labels must be provided ---
197
284
  if (effectiveLabels.length === 0) {
198
285
  result.errors.push("Missing --labels. At least one label is required, including a type label.\n" +
199
- ` Valid type labels: ${vConfig.typeLabels.join(", ")}\n` +
286
+ ` Valid type labels: ${typeLabels.join(", ")}\n` +
200
287
  ' Example: --labels "bug,backend"');
201
288
  }
202
289
  else {
203
290
  // --- Required: exactly one type label ---
204
- const typeLabelsFound = effectiveLabels.filter((l) => vConfig.typeLabels.includes(l.toLowerCase()));
291
+ const typeLabelsFound = effectiveLabels.filter((l) => typeLabels.includes(l.toLowerCase()));
205
292
  if (typeLabelsFound.length === 0) {
206
293
  result.errors.push("Missing type label. Exactly one required.\n" +
207
- ` Valid type labels: ${vConfig.typeLabels.join(", ")}\n` +
294
+ ` Valid type labels: ${typeLabels.join(", ")}\n` +
208
295
  ` Provided labels: ${effectiveLabels.join(", ")}\n` +
209
- ` Example: --labels "${vConfig.typeLabels[0]},${effectiveLabels[0]}"`);
296
+ ` Example: --labels "${typeLabels[0]},${effectiveLabels[0]}"`);
210
297
  }
211
298
  else if (typeLabelsFound.length > 1) {
212
299
  result.errors.push(`Multiple type labels found: ${typeLabelsFound.join(", ")}. Exactly one required.\n` +
213
- ` Valid type labels: ${vConfig.typeLabels.join(", ")}`);
300
+ ` Valid type labels: ${typeLabels.join(", ")}`);
214
301
  }
215
302
  }
216
303
  // --- Required: description must be provided ---
@@ -242,7 +329,7 @@ export function validateIssueCreation(input) {
242
329
  result.warnings.push("Consider starting the title with an action verb instead of an article.");
243
330
  }
244
331
  // --- Warning: title-verb / type-label alignment ---
245
- const typeLabelsFound = effectiveLabels.filter((l) => vConfig.typeLabels.includes(l.toLowerCase()));
332
+ const typeLabelsFound = effectiveLabels.filter((l) => typeLabels.includes(l.toLowerCase()));
246
333
  if (typeLabelsFound.length === 1) {
247
334
  checkTitleVerbAlignment(input.title, typeLabelsFound[0].toLowerCase(), result);
248
335
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Public output utilities — secondary entry point for cross-CLI reuse.
3
+ *
4
+ * Other Enrich Layer CLIs (el-slack, el-sheets, el-audit, el-git,
5
+ * el-elasticsearch — currently in the `tools` monorepo) should depend on
6
+ * `@enrichlayer/el-linear` and import this module to share the same
7
+ * `--jq` / `--fields` / `--raw` / `--format summary` behavior and the
8
+ * canonical `{ data, meta }` envelope shape. DEV-3799.
9
+ *
10
+ * Why this exists rather than a separate package: linctl is the OSS
11
+ * canonical source of these patterns (DEV-3619, DEV-3637, DEV-3798); it
12
+ * already publishes to npm and ships compiled JS in `dist/`. Treating it
13
+ * as the host of the shared utilities means there is exactly one
14
+ * implementation, exactly one set of tests, and exactly one place where
15
+ * the envelope contract is documented. Tools-repo CLIs pick it up via
16
+ * `pnpm add @enrichlayer/el-linear` and `import { outputSuccess, ... }
17
+ * from "@enrichlayer/el-linear/output"`.
18
+ *
19
+ * Stable API — semver-tracked. Anything re-exported here MUST stay
20
+ * backwards-compatible across minor releases. Adding exports is fine;
21
+ * renaming or removing them is a breaking change.
22
+ *
23
+ * # Wiring (consumer recipe)
24
+ *
25
+ * Each CLI's `main.ts` registers four global options and a `preAction`
26
+ * hook to read them into the shared state:
27
+ *
28
+ * ```ts
29
+ * import { Command } from "commander";
30
+ * import {
31
+ * setRawMode, setJqFilter, setFieldsFilter, setOutputFormat,
32
+ * } from "@enrichlayer/el-linear/output";
33
+ *
34
+ * const program = new Command()
35
+ * .option("--raw", "strip { data, meta } wrapper from list output")
36
+ * .option("--jq <filter>", "apply a jq filter to the JSON output")
37
+ * .option("--fields <fields>", "comma-separated field allow-list")
38
+ * .option("--format <fmt>", "json | summary", "json");
39
+ *
40
+ * program.hook("preAction", (_thisCommand, actionCommand) => {
41
+ * const o = actionCommand.optsWithGlobals();
42
+ * if (o.raw) setRawMode(true);
43
+ * if (o.jq) setJqFilter(o.jq);
44
+ * if (o.fields) {
45
+ * setFieldsFilter(o.fields.split(",").map((s: string) => s.trim()));
46
+ * }
47
+ * setOutputFormat(o.format === "summary" ? "summary" : "json");
48
+ * });
49
+ * ```
50
+ *
51
+ * Then in each subcommand handler emit results via `outputList(data)` /
52
+ * `outputSingle(data)` / `outputSuccess(payload)`, wrap async actions in
53
+ * `handleAsyncCommand`, and buffer informational messages via
54
+ * `outputWarning`. The shared state in this module takes care of `--jq`,
55
+ * `--fields`, `--raw`, and summary rendering uniformly.
56
+ *
57
+ * Adapt the option names and flags to your CLI's existing conventions —
58
+ * the contract is the four setter calls (`setRawMode`, `setJqFilter`,
59
+ * `setFieldsFilter`, `setOutputFormat`), not the literal `--jq` /
60
+ * `--fields` / `--raw` / `--format` names. A CLI that already exposes
61
+ * `--json` / `--summary` shorthands can keep them as long as the
62
+ * preAction hook ends up calling the same setters with equivalent values.
63
+ *
64
+ * # What is NOT exported
65
+ *
66
+ * - `--format summary` dispatch tables are linctl-specific (Linear
67
+ * resources). Other CLIs that want summary rendering should either
68
+ * ship `--format json` only or register their own per-command summary
69
+ * path that does not consume this module's `setOutputFormat("summary")`.
70
+ * - `outputError` is intentionally internal — it calls `process.exit(1)`
71
+ * directly. Consumers funnel errors through `handleAsyncCommand`, which
72
+ * in turn calls `outputError`. Don't re-export the bare function; the
73
+ * wrapper is the contract.
74
+ * - Token-sanitization (`sanitizeForLog`) is internal to the error path.
75
+ * Consumers that route errors through `handleAsyncCommand` automatically
76
+ * get token redaction on the error path — no separate redactor needed
77
+ * for that case. If a consumer wants sanitization for something OTHER
78
+ * than thrown errors (e.g. a custom debug log), it should pull a small
79
+ * redactor of its own; coupling token redaction to the output layer
80
+ * would make this API surface stickier than it needs to be.
81
+ */
82
+ export { type CliListEnvelope, getOutputFormat, handleAsyncCommand, type ListExtraMeta, type ListMeta, outputList, outputSingle, outputSuccess, outputWarning, resetWarnings, setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, warnIfTruncated, } from "./utils/output.js";
package/dist/output.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Public output utilities — secondary entry point for cross-CLI reuse.
3
+ *
4
+ * Other Enrich Layer CLIs (el-slack, el-sheets, el-audit, el-git,
5
+ * el-elasticsearch — currently in the `tools` monorepo) should depend on
6
+ * `@enrichlayer/el-linear` and import this module to share the same
7
+ * `--jq` / `--fields` / `--raw` / `--format summary` behavior and the
8
+ * canonical `{ data, meta }` envelope shape. DEV-3799.
9
+ *
10
+ * Why this exists rather than a separate package: linctl is the OSS
11
+ * canonical source of these patterns (DEV-3619, DEV-3637, DEV-3798); it
12
+ * already publishes to npm and ships compiled JS in `dist/`. Treating it
13
+ * as the host of the shared utilities means there is exactly one
14
+ * implementation, exactly one set of tests, and exactly one place where
15
+ * the envelope contract is documented. Tools-repo CLIs pick it up via
16
+ * `pnpm add @enrichlayer/el-linear` and `import { outputSuccess, ... }
17
+ * from "@enrichlayer/el-linear/output"`.
18
+ *
19
+ * Stable API — semver-tracked. Anything re-exported here MUST stay
20
+ * backwards-compatible across minor releases. Adding exports is fine;
21
+ * renaming or removing them is a breaking change.
22
+ *
23
+ * # Wiring (consumer recipe)
24
+ *
25
+ * Each CLI's `main.ts` registers four global options and a `preAction`
26
+ * hook to read them into the shared state:
27
+ *
28
+ * ```ts
29
+ * import { Command } from "commander";
30
+ * import {
31
+ * setRawMode, setJqFilter, setFieldsFilter, setOutputFormat,
32
+ * } from "@enrichlayer/el-linear/output";
33
+ *
34
+ * const program = new Command()
35
+ * .option("--raw", "strip { data, meta } wrapper from list output")
36
+ * .option("--jq <filter>", "apply a jq filter to the JSON output")
37
+ * .option("--fields <fields>", "comma-separated field allow-list")
38
+ * .option("--format <fmt>", "json | summary", "json");
39
+ *
40
+ * program.hook("preAction", (_thisCommand, actionCommand) => {
41
+ * const o = actionCommand.optsWithGlobals();
42
+ * if (o.raw) setRawMode(true);
43
+ * if (o.jq) setJqFilter(o.jq);
44
+ * if (o.fields) {
45
+ * setFieldsFilter(o.fields.split(",").map((s: string) => s.trim()));
46
+ * }
47
+ * setOutputFormat(o.format === "summary" ? "summary" : "json");
48
+ * });
49
+ * ```
50
+ *
51
+ * Then in each subcommand handler emit results via `outputList(data)` /
52
+ * `outputSingle(data)` / `outputSuccess(payload)`, wrap async actions in
53
+ * `handleAsyncCommand`, and buffer informational messages via
54
+ * `outputWarning`. The shared state in this module takes care of `--jq`,
55
+ * `--fields`, `--raw`, and summary rendering uniformly.
56
+ *
57
+ * Adapt the option names and flags to your CLI's existing conventions —
58
+ * the contract is the four setter calls (`setRawMode`, `setJqFilter`,
59
+ * `setFieldsFilter`, `setOutputFormat`), not the literal `--jq` /
60
+ * `--fields` / `--raw` / `--format` names. A CLI that already exposes
61
+ * `--json` / `--summary` shorthands can keep them as long as the
62
+ * preAction hook ends up calling the same setters with equivalent values.
63
+ *
64
+ * # What is NOT exported
65
+ *
66
+ * - `--format summary` dispatch tables are linctl-specific (Linear
67
+ * resources). Other CLIs that want summary rendering should either
68
+ * ship `--format json` only or register their own per-command summary
69
+ * path that does not consume this module's `setOutputFormat("summary")`.
70
+ * - `outputError` is intentionally internal — it calls `process.exit(1)`
71
+ * directly. Consumers funnel errors through `handleAsyncCommand`, which
72
+ * in turn calls `outputError`. Don't re-export the bare function; the
73
+ * wrapper is the contract.
74
+ * - Token-sanitization (`sanitizeForLog`) is internal to the error path.
75
+ * Consumers that route errors through `handleAsyncCommand` automatically
76
+ * get token redaction on the error path — no separate redactor needed
77
+ * for that case. If a consumer wants sanitization for something OTHER
78
+ * than thrown errors (e.g. a custom debug log), it should pull a small
79
+ * redactor of its own; coupling token redaction to the output layer
80
+ * would make this API surface stickier than it needs to be.
81
+ */
82
+ export { getOutputFormat, handleAsyncCommand, outputList, outputSingle, outputSuccess, outputWarning, resetWarnings, setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, warnIfTruncated, } from "./utils/output.js";
@@ -22,7 +22,7 @@ export declare const GET_ISSUE_BY_IDENTIFIER_QUERY = "\n query GetIssueByIdenti
22
22
  * by `id` (`projectsById`) so its milestones are still fetched for
23
23
  * `--project-milestone` name resolution; a name uses `projectsByName`.
24
24
  */
25
- export declare const BATCH_RESOLVE_FOR_UPDATE_QUERY = "\n query BatchResolveForUpdate(\n $projectName: String\n $projectId: ID\n $hasProjectName: Boolean = false\n $hasProjectId: Boolean = false\n $teamKey: String\n $issueNumber: Float\n $milestoneName: String\n $hasMilestoneName: Boolean = false\n ) {\n projectsByName: projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first: 1\n ) @include(if: $hasProjectName) {\n nodes {\n id\n name\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n\n projectsById: projects(\n filter: { id: { eq: $projectId } }\n first: 1\n ) @include(if: $hasProjectId) {\n nodes {\n id\n name\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n\n milestones: projectMilestones(\n filter: { name: { eq: $milestoneName } }\n first: 1\n ) @include(if: $hasMilestoneName) {\n nodes {\n id\n name\n }\n }\n\n issues(\n filter: {\n and: [\n { team: { key: { eq: $teamKey } } }\n { number: { eq: $issueNumber } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n identifier\n team {\n id\n key\n }\n labels {\n nodes {\n id\n name\n }\n }\n project {\n id\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n }\n }\n";
25
+ export declare const BATCH_RESOLVE_FOR_UPDATE_QUERY = "\n query BatchResolveForUpdate(\n $projectName: String\n $projectId: ID\n $hasProjectName: Boolean = false\n $hasProjectId: Boolean = false\n $teamKey: String\n $issueNumber: Float\n $milestoneName: String\n $hasMilestoneName: Boolean = false\n ) {\n projectsByName: projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first: 5\n ) @include(if: $hasProjectName) {\n nodes {\n id\n name\n teams {\n nodes { id key }\n }\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n\n projectsById: projects(\n filter: { id: { eq: $projectId } }\n first: 1\n ) @include(if: $hasProjectId) {\n nodes {\n id\n name\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n\n milestones: projectMilestones(\n filter: { name: { eq: $milestoneName } }\n first: 1\n ) @include(if: $hasMilestoneName) {\n nodes {\n id\n name\n }\n }\n\n issues(\n filter: {\n and: [\n { team: { key: { eq: $teamKey } } }\n { number: { eq: $issueNumber } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n identifier\n team {\n id\n key\n }\n labels {\n nodes {\n id\n name\n }\n }\n project {\n id\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n }\n }\n";
26
26
  export declare const CREATE_ISSUE_MUTATION = "\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
27
27
  export declare const UPDATE_ISSUE_MUTATION = "\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
28
28
  export declare const ARCHIVE_ISSUE_MUTATION = "\n mutation ArchiveIssue($id: String!) {\n issueArchive(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
@@ -46,7 +46,7 @@ export declare const DELETE_ISSUE_MUTATION = "\n mutation DeleteIssue($id: Stri
46
46
  * so they carry distinct aliases and the service folds whichever ran
47
47
  * into `resolveResult.projects`.
48
48
  */
49
- export declare const BATCH_RESOLVE_FOR_CREATE_QUERY = "\n query BatchResolveForCreate(\n $teamKey: String\n $teamName: String\n $projectName: String\n $projectId: ID\n $hasProjectName: Boolean = false\n $hasProjectId: Boolean = false\n $parentTeamKey: String\n $parentIssueNumber: Float\n $milestoneName: String\n $hasMilestoneName: Boolean = false\n ) {\n teams(\n filter: {\n or: [\n { key: { eq: $teamKey } }\n { name: { eqIgnoreCase: $teamName } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n key\n name\n }\n }\n\n projectsByName: projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first: 1\n ) @include(if: $hasProjectName) {\n nodes {\n id\n name\n teams {\n nodes { id key }\n }\n projectMilestones {\n nodes { id name }\n }\n }\n }\n\n projectsById: projects(\n filter: { id: { eq: $projectId } }\n first: 1\n ) @include(if: $hasProjectId) {\n nodes {\n id\n name\n teams {\n nodes { id key }\n }\n projectMilestones {\n nodes { id name }\n }\n }\n }\n\n milestones: projectMilestones(\n filter: { name: { eq: $milestoneName } }\n first: 1\n ) @include(if: $hasMilestoneName) {\n nodes {\n id\n name\n }\n }\n\n parentIssues: issues(\n filter: {\n and: [\n { team: { key: { eq: $parentTeamKey } } }\n { number: { eq: $parentIssueNumber } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n identifier\n }\n }\n }\n";
49
+ export declare const BATCH_RESOLVE_FOR_CREATE_QUERY = "\n query BatchResolveForCreate(\n $teamKey: String\n $teamName: String\n $projectName: String\n $projectId: ID\n $hasProjectName: Boolean = false\n $hasProjectId: Boolean = false\n $parentTeamKey: String\n $parentIssueNumber: Float\n $milestoneName: String\n $hasMilestoneName: Boolean = false\n ) {\n teams(\n filter: {\n or: [\n { key: { eq: $teamKey } }\n { name: { eqIgnoreCase: $teamName } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n key\n name\n }\n }\n\n projectsByName: projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first: 5\n ) @include(if: $hasProjectName) {\n nodes {\n id\n name\n teams {\n nodes { id key }\n }\n projectMilestones {\n nodes { id name }\n }\n }\n }\n\n projectsById: projects(\n filter: { id: { eq: $projectId } }\n first: 1\n ) @include(if: $hasProjectId) {\n nodes {\n id\n name\n teams {\n nodes { id key }\n }\n projectMilestones {\n nodes { id name }\n }\n }\n }\n\n milestones: projectMilestones(\n filter: { name: { eq: $milestoneName } }\n first: 1\n ) @include(if: $hasMilestoneName) {\n nodes {\n id\n name\n }\n }\n\n parentIssues: issues(\n filter: {\n and: [\n { team: { key: { eq: $parentTeamKey } } }\n { number: { eq: $parentIssueNumber } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n identifier\n }\n }\n }\n";
50
50
  /**
51
51
  * Build a label-resolution query filtered by names (case-insensitive).
52
52
  * Uses `or` + `eqIgnoreCase` since Linear's `in` filter may be case-sensitive.
@@ -146,11 +146,14 @@ export const BATCH_RESOLVE_FOR_UPDATE_QUERY = `
146
146
  ) {
147
147
  projectsByName: projects(
148
148
  filter: { name: { eqIgnoreCase: $projectName } }
149
- first: 1
149
+ first: 5
150
150
  ) @include(if: $hasProjectName) {
151
151
  nodes {
152
152
  id
153
153
  name
154
+ teams {
155
+ nodes { id key }
156
+ }
154
157
  projectMilestones {
155
158
  nodes {
156
159
  id
@@ -313,7 +316,7 @@ export const BATCH_RESOLVE_FOR_CREATE_QUERY = `
313
316
 
314
317
  projectsByName: projects(
315
318
  filter: { name: { eqIgnoreCase: $projectName } }
316
- first: 1
319
+ first: 5
317
320
  ) @include(if: $hasProjectName) {
318
321
  nodes {
319
322
  id
@@ -175,6 +175,19 @@ export declare class GraphQLIssuesService {
175
175
  private executeCreateMutation;
176
176
  searchIssues(args: SearchIssueArgs): Promise<LinearIssue[]>;
177
177
  private resolveTeamId;
178
+ /**
179
+ * Pick a project ID from the batched name-lookup result.
180
+ *
181
+ * The batch query fetches up to 5 candidates by name (DEV-4103) so that
182
+ * a name shared across teams doesn't silently bind to whichever project
183
+ * Linear returned first. When a `--team` was supplied, prefer the
184
+ * candidate whose team set includes it; the downstream
185
+ * `validateProjectTeam` will then no-op (the team already matches).
186
+ * When multiple candidates exist and none of them are on the requested
187
+ * team (or no team was supplied), throw an explicit ambiguous-project
188
+ * error listing the candidate teams — far better than the prior silent
189
+ * cross-team match.
190
+ */
178
191
  private resolveProjectId;
179
192
  /**
180
193
  * Check that the resolved team is associated with the project.
@@ -3,7 +3,7 @@ import { ARCHIVE_ISSUE_MUTATION, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_F
3
3
  import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
4
4
  import { toISOStringOrNow } from "./date-format.js";
5
5
  import { extractEmbeds } from "./embed-parser.js";
6
- import { notFoundError } from "./error-messages.js";
6
+ import { multipleMatchesError, notFoundError } from "./error-messages.js";
7
7
  import { parseIssueIdentifier, tryParseIssueIdentifier, } from "./identifier-parser.js";
8
8
  import { logger } from "./logger.js";
9
9
  import { isUuid } from "./uuid.js";
@@ -111,8 +111,15 @@ export class GraphQLIssuesService {
111
111
  teamIdForLabels = await this.fetchIssueTeamId(resolvedIssueId);
112
112
  }
113
113
  const finalLabelIds = await this.resolveLabelsWithMode(normalizedArgs.labelIds, resolveResult, labelMode, currentIssueLabels, teamIdForLabels, normalizedArgs.teamId);
114
+ // DEV-4103: scope project disambiguation by the issue's existing team
115
+ // so a `--project` name shared across teams binds to the issue's team
116
+ // rather than whichever project Linear happened to return first.
117
+ // `issueTeamId` is the right scope here — it's always a UUID and
118
+ // represents the team the issue currently lives on. `--team` is rare
119
+ // on update (and would imply a cross-team move that goes through
120
+ // separate validation paths anyway).
114
121
  const finalProjectId = normalizedArgs.projectId
115
- ? this.resolveProjectId(normalizedArgs.projectId, resolveResult)
122
+ ? this.resolveProjectId(normalizedArgs.projectId, resolveResult, issueTeamId)
116
123
  : undefined;
117
124
  const { projectMilestoneNodes, issueProjectMilestoneNodes } = this.extractMilestoneNodes(normalizedArgs, resolveResult);
118
125
  const finalMilestoneId = this.resolveMilestoneId(normalizedArgs.milestoneId, resolveResult, projectMilestoneNodes, issueProjectMilestoneNodes);
@@ -272,7 +279,7 @@ export class GraphQLIssuesService {
272
279
  ? await this.resolveTeamId(args.teamId, resolveResult)
273
280
  : undefined;
274
281
  const projectId = args.projectId
275
- ? this.resolveProjectId(args.projectId, resolveResult)
282
+ ? this.resolveProjectId(args.projectId, resolveResult, teamId)
276
283
  : undefined;
277
284
  // Validate team-project compatibility and auto-correct when possible
278
285
  if (projectId && teamId && args.projectId) {
@@ -423,7 +430,20 @@ export class GraphQLIssuesService {
423
430
  // Exact GraphQL match failed — fall back to prefix matching via LinearService
424
431
  return this.linearService.resolveTeamId(teamId);
425
432
  }
426
- resolveProjectId(projectId, resolveResult) {
433
+ /**
434
+ * Pick a project ID from the batched name-lookup result.
435
+ *
436
+ * The batch query fetches up to 5 candidates by name (DEV-4103) so that
437
+ * a name shared across teams doesn't silently bind to whichever project
438
+ * Linear returned first. When a `--team` was supplied, prefer the
439
+ * candidate whose team set includes it; the downstream
440
+ * `validateProjectTeam` will then no-op (the team already matches).
441
+ * When multiple candidates exist and none of them are on the requested
442
+ * team (or no team was supplied), throw an explicit ambiguous-project
443
+ * error listing the candidate teams — far better than the prior silent
444
+ * cross-team match.
445
+ */
446
+ resolveProjectId(projectId, resolveResult, teamId) {
427
447
  if (isUuid(projectId)) {
428
448
  // The create/update batch queries fetch a `projectsById` block for a
429
449
  // UUID `--project` (folded into `resolveResult.projects`). An empty
@@ -443,7 +463,25 @@ export class GraphQLIssuesService {
443
463
  if (!projectNodes?.length) {
444
464
  throw notFoundError("Project", projectId);
445
465
  }
446
- return projectNodes[0].id;
466
+ if (projectNodes.length === 1) {
467
+ return projectNodes[0].id;
468
+ }
469
+ // Multiple matches — disambiguate by team when possible.
470
+ if (teamId) {
471
+ const teamMatches = projectNodes.filter((n) => n.teams?.nodes.some((t) => t.id === teamId));
472
+ if (teamMatches.length === 1) {
473
+ return teamMatches[0].id;
474
+ }
475
+ if (teamMatches.length > 1) {
476
+ // Same name, same team — extremely rare but Linear permits it.
477
+ // Fall through to ambiguity error so the user picks by URL/slug.
478
+ }
479
+ }
480
+ const descriptions = projectNodes.map((n) => {
481
+ const teamKeys = n.teams?.nodes.map((t) => t.key) ?? [];
482
+ return `${n.id} (teams: ${teamKeys.join(", ") || "none"})`;
483
+ });
484
+ throw multipleMatchesError("project", projectId, descriptions, "scope with --team, or pass the project URL/slug-id");
447
485
  }
448
486
  /**
449
487
  * Check that the resolved team is associated with the project.
@@ -35,7 +35,21 @@ export declare class LinearService {
35
35
  getCycles(teamFilter?: string, activeOnly?: boolean, limit?: number): Promise<LinearCycleSummary[]>;
36
36
  getCycleById(cycleId: string, issuesLimit?: number): Promise<LinearCycleDetail>;
37
37
  resolveCycleId(cycleNameOrId: string, teamFilter?: string): Promise<string>;
38
- resolveProjectId(projectInput: string): Promise<string>;
38
+ /**
39
+ * Resolve a user-supplied project identifier to a UUID.
40
+ *
41
+ * Inputs (in order): UUID (pass-through), Linear URL or slug-id pair
42
+ * (unique by slug, resolved via `slugId` filter), or a plain name (case
43
+ * insensitive). DEV-4103: when a name resolution returns multiple matches
44
+ * across teams, the resolver disambiguates by `--team` when provided and
45
+ * throws an `ambiguous project` error otherwise — replacing the prior
46
+ * silent `first: 1` pick that could cross team boundaries.
47
+ *
48
+ * `teamInput` is the same string a user would pass to `--team`
49
+ * (key / name / UUID); it is resolved here when present so callers don't
50
+ * have to double-resolve.
51
+ */
52
+ resolveProjectId(projectInput: string, teamInput?: string): Promise<string>;
39
53
  /**
40
54
  * Normalize a user-supplied project input to a UUID when the input is
41
55
  * a URL or slug-id form. Pass-through for UUIDs and plain names — the
@@ -471,7 +471,21 @@ export class LinearService {
471
471
  }
472
472
  return chosen.id;
473
473
  }
474
- async resolveProjectId(projectInput) {
474
+ /**
475
+ * Resolve a user-supplied project identifier to a UUID.
476
+ *
477
+ * Inputs (in order): UUID (pass-through), Linear URL or slug-id pair
478
+ * (unique by slug, resolved via `slugId` filter), or a plain name (case
479
+ * insensitive). DEV-4103: when a name resolution returns multiple matches
480
+ * across teams, the resolver disambiguates by `--team` when provided and
481
+ * throws an `ambiguous project` error otherwise — replacing the prior
482
+ * silent `first: 1` pick that could cross team boundaries.
483
+ *
484
+ * `teamInput` is the same string a user would pass to `--team`
485
+ * (key / name / UUID); it is resolved here when present so callers don't
486
+ * have to double-resolve.
487
+ */
488
+ async resolveProjectId(projectInput, teamInput) {
475
489
  if (isUuid(projectInput)) {
476
490
  return projectInput;
477
491
  }
@@ -501,15 +515,46 @@ export class LinearService {
501
515
  // not-found error as the name path.
502
516
  throw notFoundError("Project", projectInput);
503
517
  }
504
- const filter = { name: { eqIgnoreCase: projectInput } };
518
+ // DEV-4103: scope by team when provided so a name shared across teams
519
+ // doesn't resolve to a different team's project. Same SDK filter shape
520
+ // `getProjects` already uses for `--team` filtering.
521
+ const teamId = teamInput ? await this.resolveTeamId(teamInput) : undefined;
522
+ const filter = {
523
+ name: { eqIgnoreCase: projectInput },
524
+ };
525
+ if (teamId) {
526
+ // Matches the filter shape `getProjects` uses for `--team`.
527
+ filter.teams = { some: { id: { eq: teamId } } };
528
+ }
529
+ // `first: 5` is wide enough to detect ambiguity (same name across
530
+ // multiple teams) without paying for a deeper page. Linear's UI caps
531
+ // effective project-name collisions at a handful in practice; if a
532
+ // workspace ever exceeds this we'll see it as a still-ambiguous error
533
+ // listing the first 5 teams — better than a silent wrong pick.
505
534
  const projectsConnection = await this.client.projects({
506
535
  filter,
507
- first: 1,
536
+ first: 5,
508
537
  });
509
538
  if (projectsConnection.nodes.length === 0) {
510
- throw notFoundError("Project", projectInput);
539
+ const context = teamInput ? `on team "${teamInput}"` : undefined;
540
+ throw notFoundError("Project", projectInput, context);
541
+ }
542
+ if (projectsConnection.nodes.length === 1) {
543
+ return projectsConnection.nodes[0].id;
511
544
  }
512
- return projectsConnection.nodes[0].id;
545
+ // Multiple candidates — collect team keys per candidate so the error
546
+ // names them concretely. Bounded N ≤ 5 keeps the per-edge resolver
547
+ // promise cheap (handful of round-trips on the rare ambiguous path).
548
+ const candidates = await Promise.all(projectsConnection.nodes.map(async (p) => {
549
+ const teams = await p.teams();
550
+ return {
551
+ id: p.id,
552
+ name: p.name,
553
+ teamKeys: teams.nodes.map((t) => t.key),
554
+ };
555
+ }));
556
+ const descriptions = candidates.map((c) => `${c.id} (teams: ${c.teamKeys.join(", ") || "none"})`);
557
+ throw multipleMatchesError("project", projectInput, descriptions, "scope with --team, or pass the project URL/slug-id");
513
558
  }
514
559
  /**
515
560
  * Normalize a user-supplied project input to a UUID when the input is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
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",
@@ -9,6 +9,11 @@
9
9
  ".": {
10
10
  "types": "./dist/main.d.ts",
11
11
  "import": "./dist/main.js"
12
+ },
13
+ "./output": {
14
+ "types": "./dist/output.d.ts",
15
+ "import": "./dist/output.js",
16
+ "default": "./dist/output.js"
12
17
  }
13
18
  },
14
19
  "sideEffects": false,