@enrichlayer/el-linear 1.43.0 → 1.44.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.
@@ -10,6 +10,14 @@
10
10
  * OPT-IN by design. el-linear is open source, so the gate is dormant unless a
11
11
  * workspace config sets `validation.intakeDecisionGate` to `"warn"` or
12
12
  * `"block"`.
13
+ *
14
+ * DEV-7074: the parser accepts the markdown an author actually types — any
15
+ * list marker (`-`, `*`, `+`, `1.`) and bolded or italicized labels with the
16
+ * colon inside (`**Needed:**`) or outside (`**Needed**:`) the emphasis run.
17
+ * When a field genuinely fails, the diagnostic names WHICH failure it is:
18
+ * an unreadable line, an absent field, an empty value, a placeholder, or a
19
+ * present-but-unjudged value. Reporting a formatting mismatch as empty content
20
+ * sent authors to rewrite prose that was already correct.
13
21
  */
14
22
  import { extractField, stripFencedCodeBlocks } from "../utils/extract-field.js";
15
23
  import { loadConfig } from "./config.js";
@@ -34,25 +42,116 @@ const FIELD_DEFINITIONS = [
34
42
  { key: "placement", label: "Placement" },
35
43
  { key: "decision", label: "Decision" },
36
44
  ];
37
- const FIELD_LINE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)?(?:\*\*)?(Needed|Worth doing|Existing work|Owner|Placement|Decision)(?:\*\*)?\s*:\s*(.*?)\s*$/gim;
45
+ /**
46
+ * A field line, split at its FIRST colon: an optional list marker or ordinal,
47
+ * the label part, then the value part. Emphasis markers are deliberately not
48
+ * described here — the label and value are normalized separately, because
49
+ * markdown lets the colon fall either inside (`**Needed:**`) or outside
50
+ * (`**Needed**:`) the emphasis run (DEV-7074).
51
+ */
52
+ const FIELD_LINE = /^[ \t]*(?:[-*+][ \t]+|\d+[.)][ \t]+)?([^:\n]*):(.*)$/;
53
+ /** Leading / trailing markdown emphasis run (`**`, `__`, `*`, `_`). */
54
+ const LEADING_EMPHASIS = /^[*_]{1,3}\s*/;
55
+ const TRAILING_EMPHASIS = /\s*[*_]{1,3}$/;
56
+ /** A value entirely wrapped in one emphasis run, e.g. `**PROCEED**`. */
57
+ const WRAPPED_IN_EMPHASIS = /^([*_]{1,3})([^*_].*?)\1$/;
58
+ /**
59
+ * A paired inline emphasis run at a token boundary. The boundaries are
60
+ * load-bearing: a literal underscore in `PRO_CEED` or `customer_support` is
61
+ * content, not markdown, and must not disappear during semantic validation.
62
+ */
63
+ const INLINE_EMPHASIS = /(^|[^\p{L}\p{N}])([*_]{1,3})(?=\S)(.+?\S)\2(?=$|[^\p{L}\p{N}])/gu;
38
64
  const PLACEHOLDER = /^(?:tbd|todo|unknown|n\/?a|none|unsure|not decided|-)\.?$/i;
39
65
  const NON_SPECIFIC = /^(?:yes|no)$/i;
40
66
  const AFFIRMATIVE_WITH_REASON = /^yes\s*(?:[-—:;,]|because)\s*(\S.{2,})$/i;
67
+ function labelOf(key) {
68
+ return FIELD_DEFINITIONS.find((field) => field.key === key)?.label ?? key;
69
+ }
41
70
  function normalizedKey(label) {
42
- switch (label.toLowerCase()) {
43
- case "needed":
44
- return "needed";
45
- case "worth doing":
46
- return "worth";
47
- case "existing work":
48
- return "existing";
49
- case "owner":
50
- return "owner";
51
- case "placement":
52
- return "placement";
53
- default:
54
- return "decision";
71
+ const normalized = label.toLowerCase().trim().replace(/\s+/g, " ");
72
+ return (FIELD_DEFINITIONS.find((field) => field.label.toLowerCase() === normalized)
73
+ ?.key ?? null);
74
+ }
75
+ /** Strip the emphasis markers wrapping a label, e.g. `**Needed**` -> `Needed`. */
76
+ function stripLabelEmphasis(raw) {
77
+ const trimmed = raw.trim();
78
+ const opened = LEADING_EMPHASIS.test(trimmed);
79
+ const closed = TRAILING_EMPHASIS.test(trimmed);
80
+ return {
81
+ label: trimmed.replace(LEADING_EMPHASIS, "").replace(TRAILING_EMPHASIS, ""),
82
+ opensEmphasis: opened && !closed,
83
+ };
84
+ }
85
+ function normalizeFieldValue(raw, labelOpensEmphasis) {
86
+ let value = raw.trim();
87
+ if (labelOpensEmphasis) {
88
+ // The emphasis run heading the value closes the label's bold, it is not
89
+ // part of the value: `* **Needed:** Yes — …`.
90
+ value = value.replace(LEADING_EMPHASIS, "").trim();
91
+ }
92
+ const wrapped = value.match(WRAPPED_IN_EMPHASIS);
93
+ return (wrapped?.[2] ?? value).trim();
94
+ }
95
+ /**
96
+ * The value with inline emphasis removed, used for the SEMANTIC checks only.
97
+ * `- Decision: **PROCEED**` and `- Needed: **Yes** — …` are the same judgments
98
+ * as their unemphasized forms. Diagnostics report the parsed value, which
99
+ * normalizes a whole-value wrapper but preserves emphasis within longer prose.
100
+ */
101
+ function withoutEmphasis(value) {
102
+ let normalized = value;
103
+ let previous;
104
+ do {
105
+ previous = normalized;
106
+ normalized = normalized.replace(INLINE_EMPHASIS, "$1$3");
107
+ } while (normalized !== previous);
108
+ return normalized.trim();
109
+ }
110
+ function parseFieldLine(line) {
111
+ const match = line.match(FIELD_LINE);
112
+ if (!match)
113
+ return null;
114
+ const { label, opensEmphasis } = stripLabelEmphasis(match[1] ?? "");
115
+ const key = normalizedKey(label);
116
+ if (key === null)
117
+ return null;
118
+ return { key, value: normalizeFieldValue(match[2] ?? "", opensEmphasis) };
119
+ }
120
+ /**
121
+ * Find a line that names `label` but did not parse as `Label: value` — a
122
+ * formatting failure, not absent content. Reported as `unparsed-field` so the
123
+ * author fixes the line instead of writing a field they already wrote.
124
+ */
125
+ function findUnparsedLine(lines, label) {
126
+ // Built from FIELD_DEFINITIONS' fixed labels (no regex metacharacters).
127
+ const names = new RegExp(`^[ \\t]*(?:[-*+][ \\t]+|\\d+[.)][ \\t]+)?[*_]{0,3}${label}\\b`, "i");
128
+ for (const line of lines) {
129
+ if (names.test(line) && parseFieldLine(line) === null)
130
+ return line.trim();
131
+ }
132
+ return null;
133
+ }
134
+ function classifyValue(key, value) {
135
+ if (value.length === 0)
136
+ return "empty";
137
+ const probe = withoutEmphasis(value);
138
+ if (probe.length === 0)
139
+ return "empty";
140
+ if (PLACEHOLDER.test(probe))
141
+ return "placeholder";
142
+ if (key === "needed" || key === "worth") {
143
+ const match = probe.match(AFFIRMATIVE_WITH_REASON);
144
+ if (!match)
145
+ return "no-judgment";
146
+ return PLACEHOLDER.test(match[1]?.trim() ?? "")
147
+ ? "placeholder-reason"
148
+ : null;
55
149
  }
150
+ // Measure the parsed value rather than the semantic probe so removing inline
151
+ // emphasis markers cannot make substantive content look artificially short.
152
+ if (value.length < 3 || NON_SPECIFIC.test(probe))
153
+ return "non-specific";
154
+ return null;
56
155
  }
57
156
  export function evaluateIntakeDecision(description, headers = DEFAULT_INTAKE_SECTION_HEADERS) {
58
157
  let section = null;
@@ -69,28 +168,36 @@ export function evaluateIntakeDecision(description, headers = DEFAULT_INTAKE_SEC
69
168
  }
70
169
  // A template/example fence is not an operative decision record. Remove all
71
170
  // fenced examples before matching so copied guidance cannot satisfy intake.
72
- const operativeSection = stripFencedCodeBlocks(section);
171
+ const lines = stripFencedCodeBlocks(section).split("\n");
73
172
  const values = new Map();
74
- for (const match of operativeSection.matchAll(FIELD_LINE)) {
75
- const key = normalizedKey(match[1] ?? "");
76
- if (values.has(key)) {
173
+ for (const [index, line] of lines.entries()) {
174
+ const parsed = parseFieldLine(line);
175
+ if (!parsed)
176
+ continue;
177
+ if (values.has(parsed.key)) {
77
178
  return {
78
179
  ok: false,
79
180
  reason: "duplicate-field",
80
- field: FIELD_DEFINITIONS.find((field) => field.key === key)?.label ?? key,
181
+ field: labelOf(parsed.key),
81
182
  };
82
183
  }
83
- values.set(key, { value: match[2]?.trim() ?? "", index: match.index });
184
+ values.set(parsed.key, { value: parsed.value, index });
84
185
  }
85
186
  let previousIndex = -1;
86
187
  for (const definition of FIELD_DEFINITIONS) {
87
188
  const entry = values.get(definition.key);
88
189
  if (!entry) {
89
- return {
90
- ok: false,
91
- reason: "missing-field",
92
- field: definition.label,
93
- };
190
+ // Distinguish "the field is absent" from "the field is there but its
191
+ // line does not parse" — different authoring fixes (DEV-7074).
192
+ const unparsed = findUnparsedLine(lines, definition.label);
193
+ return unparsed === null
194
+ ? { ok: false, reason: "missing-field", field: definition.label }
195
+ : {
196
+ ok: false,
197
+ reason: "unparsed-field",
198
+ field: definition.label,
199
+ line: unparsed,
200
+ };
94
201
  }
95
202
  if (entry.index < previousIndex) {
96
203
  return {
@@ -101,40 +208,55 @@ export function evaluateIntakeDecision(description, headers = DEFAULT_INTAKE_SEC
101
208
  }
102
209
  previousIndex = entry.index;
103
210
  }
104
- for (const key of ["needed", "worth"]) {
211
+ for (const key of [
212
+ "needed",
213
+ "worth",
214
+ "existing",
215
+ "owner",
216
+ "placement",
217
+ ]) {
105
218
  const value = values.get(key)?.value ?? "";
106
- const match = value.match(AFFIRMATIVE_WITH_REASON);
107
- const reason = match?.[1]?.trim() ?? "";
108
- if (!match || PLACEHOLDER.test(reason)) {
219
+ const problem = classifyValue(key, value);
220
+ if (problem !== null) {
109
221
  return {
110
222
  ok: false,
111
223
  reason: "invalid-field",
112
- field: key === "needed" ? "Needed" : "Worth doing",
113
- };
114
- }
115
- }
116
- for (const key of ["existing", "owner", "placement"]) {
117
- const value = values.get(key)?.value ?? "";
118
- if (value.length < 3 ||
119
- PLACEHOLDER.test(value) ||
120
- NON_SPECIFIC.test(value)) {
121
- return {
122
- ok: false,
123
- reason: "invalid-field",
124
- field: key === "existing"
125
- ? "Existing work"
126
- : key === "owner"
127
- ? "Owner"
128
- : "Placement",
224
+ field: labelOf(key),
225
+ problem,
226
+ value,
129
227
  };
130
228
  }
131
229
  }
132
230
  const decision = values.get("decision")?.value ?? "";
133
- if (!/^proceed$/i.test(decision)) {
231
+ if (!/^proceed$/i.test(withoutEmphasis(decision))) {
134
232
  return { ok: false, reason: "not-proceeding", decision };
135
233
  }
136
234
  return { ok: true, header: matchedHeader };
137
235
  }
236
+ /** Quote what the author actually wrote, bounded so a long line stays readable. */
237
+ function quote(text, limit = 80) {
238
+ const single = text.replace(/\s+/g, " ").trim();
239
+ return single.length > limit ? `${single.slice(0, limit - 1)}…` : single;
240
+ }
241
+ /**
242
+ * Name the ACTUAL failure. A message that blames content for a formatting
243
+ * mismatch sends the author to rewrite prose that was already correct
244
+ * (DEV-7074), so each problem gets its own remedy.
245
+ */
246
+ function describeFieldProblem(field, problem, value) {
247
+ switch (problem) {
248
+ case "empty":
249
+ return `The intake field "${field}" parsed, but has no content after the label.`;
250
+ case "placeholder":
251
+ return `The intake field "${field}" is the placeholder "${quote(value)}"; record a real value.`;
252
+ case "non-specific":
253
+ return `The intake field "${field}" reads "${quote(value)}", which is too short or too generic to be a specific answer.`;
254
+ case "no-judgment":
255
+ return `The intake field "${field}" reads "${quote(value)}" — the content is there, but it is not an explicit "Yes — <reason>" judgment.`;
256
+ case "placeholder-reason":
257
+ return `The intake field "${field}" says Yes but gives the placeholder reason "${quote(value)}"; record the real reason.`;
258
+ }
259
+ }
138
260
  export function formatIntakeDecisionBlock(opts) {
139
261
  const { evaluation } = opts;
140
262
  let reason;
@@ -143,7 +265,10 @@ export function formatIntakeDecisionBlock(opts) {
143
265
  reason = `Issue description has no intake section (looked for: ${opts.headers.join(", ")}).`;
144
266
  break;
145
267
  case "missing-field":
146
- reason = `The intake decision is missing the "${evaluation.field}" field.`;
268
+ reason = `The intake decision is missing the "${evaluation.field}" field — no line records it.`;
269
+ break;
270
+ case "unparsed-field":
271
+ reason = `The intake decision's "${evaluation.field}" line is not in "${evaluation.field}: <value>" form, so it was not read as a field: "${quote(evaluation.line)}". This is a formatting mismatch, not missing content — the label must be followed by a colon. Bolded labels are accepted in either form (\`- **${evaluation.field}:** <value>\` or \`- **${evaluation.field}**: <value>\`).`;
147
272
  break;
148
273
  case "duplicate-field":
149
274
  reason = `The intake decision repeats the "${evaluation.field}" field; record one unambiguous value.`;
@@ -152,7 +277,7 @@ export function formatIntakeDecisionBlock(opts) {
152
277
  reason = `The intake fields are out of order at "${evaluation.field}".`;
153
278
  break;
154
279
  case "invalid-field":
155
- reason = `The intake field "${evaluation.field}" is empty, a placeholder, or lacks an explicit yes-and-reason judgment.`;
280
+ reason = describeFieldProblem(evaluation.field, evaluation.problem, evaluation.value);
156
281
  break;
157
282
  case "not-proceeding":
158
283
  reason = `The intake decision is "${evaluation.decision || "empty"}", not PROCEED.`;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Non-blocking repo/profile mismatch hint (DEV-7277).
3
+ *
4
+ * On write commands (`issues create`, `comments create`) a user who works
5
+ * across multiple Linear workspaces can silently write into the wrong one: the
6
+ * machine-global `active-profile` marker is whatever they last `profile use`d,
7
+ * and it applies to every repo that hasn't pinned a profile. This prints a
8
+ * ONE-LINE hint to stderr when — and only when — the active profile came from
9
+ * that bare global marker AND the current repo has no pin, nudging the user to
10
+ * `el-linear profile pin`.
11
+ *
12
+ * It NEVER blocks the command and NEVER writes to stdout (that stays reserved
13
+ * for the machine-parseable JSON envelope). It is suppressed by `--quiet` and
14
+ * by the `EL_LINEAR_NO_PIN_HINT` opt-out.
15
+ */
16
+ import { type RepoLinearProfile } from "./repo-profile.js";
17
+ export interface PinHintDeps {
18
+ env: NodeJS.ProcessEnv;
19
+ cwd: string;
20
+ /** True when `--profile` was passed for this invocation. */
21
+ hasProfileFlag: boolean;
22
+ /** Whether `--quiet` is active. */
23
+ isQuiet: () => boolean;
24
+ /** Raw `active-profile` marker content (bypasses the flag/env layers). */
25
+ readMarker: () => string | null;
26
+ /** Repo→profile pin lookup for `cwd`. */
27
+ resolveRepoPin: (cwd: string) => RepoLinearProfile | null;
28
+ /** Sink for the one-line hint (stderr in production). */
29
+ write: (message: string) => void;
30
+ }
31
+ /**
32
+ * Emit the mismatch hint when applicable. Returns true when a hint was written
33
+ * (for tests). All decision inputs are injectable so the trigger/suppression
34
+ * matrix can be unit-tested without git, env, or process state.
35
+ */
36
+ export declare function maybeEmitPinMismatchHint(deps: Pick<PinHintDeps, "hasProfileFlag"> & Partial<PinHintDeps>): boolean;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Non-blocking repo/profile mismatch hint (DEV-7277).
3
+ *
4
+ * On write commands (`issues create`, `comments create`) a user who works
5
+ * across multiple Linear workspaces can silently write into the wrong one: the
6
+ * machine-global `active-profile` marker is whatever they last `profile use`d,
7
+ * and it applies to every repo that hasn't pinned a profile. This prints a
8
+ * ONE-LINE hint to stderr when — and only when — the active profile came from
9
+ * that bare global marker AND the current repo has no pin, nudging the user to
10
+ * `el-linear profile pin`.
11
+ *
12
+ * It NEVER blocks the command and NEVER writes to stdout (that stays reserved
13
+ * for the machine-parseable JSON envelope). It is suppressed by `--quiet` and
14
+ * by the `EL_LINEAR_NO_PIN_HINT` opt-out.
15
+ */
16
+ import { getQuietMode } from "../utils/output.js";
17
+ import { isSafeProfileName, readActiveProfileMarker } from "./paths.js";
18
+ import { resolveRepoLinearProfile, } from "./repo-profile.js";
19
+ const ENV_OPT_IN_VALUES = new Set(["1", "true", "yes", "on"]);
20
+ /** True only when `env[name]` is an explicit opt-in value (not `0`/`false`). */
21
+ function isEnvOptIn(name, env) {
22
+ const raw = env[name]?.trim().toLowerCase();
23
+ return raw !== undefined && ENV_OPT_IN_VALUES.has(raw);
24
+ }
25
+ /**
26
+ * Emit the mismatch hint when applicable. Returns true when a hint was written
27
+ * (for tests). All decision inputs are injectable so the trigger/suppression
28
+ * matrix can be unit-tested without git, env, or process state.
29
+ */
30
+ export function maybeEmitPinMismatchHint(deps) {
31
+ const env = deps.env ?? process.env;
32
+ const cwd = deps.cwd ?? process.cwd();
33
+ const isQuiet = deps.isQuiet ?? getQuietMode;
34
+ const readMarker = deps.readMarker ?? (() => readActiveProfileMarker());
35
+ const resolveRepoPin = deps.resolveRepoPin ?? ((c) => resolveRepoLinearProfile(c));
36
+ const write = deps.write ?? ((m) => process.stderr.write(`${m}\n`));
37
+ // Suppressed contexts — every one of these means the hint would be noise.
38
+ if (isQuiet())
39
+ return false;
40
+ if (isEnvOptIn("EL_LINEAR_NO_PIN_HINT", env))
41
+ return false;
42
+ // Came from `--profile` — the user was explicit.
43
+ if (deps.hasProfileFlag)
44
+ return false;
45
+ // Came from `$EL_LINEAR_PROFILE` — also explicit.
46
+ if ((env.EL_LINEAR_PROFILE ?? "").trim())
47
+ return false;
48
+ // Repo already has an effective pin — nothing to nudge. Startup ignores
49
+ // unsafe names, so the hint must apply the same predicate or a bad pin would
50
+ // suppress the warning while the write falls through to the global marker.
51
+ const pin = resolveRepoPin(cwd);
52
+ if (pin && isSafeProfileName(pin.profile))
53
+ return false;
54
+ // Only fire when the profile came from the BARE global marker. No marker =
55
+ // legacy single-profile default; that user hasn't opted into workspaces at
56
+ // all, so the nudge would be premature.
57
+ const marker = readMarker();
58
+ if (!marker)
59
+ return false;
60
+ write(`el-linear: writing with the machine-global active profile "${marker}" — this repo has no pinned workspace. ` +
61
+ "If it belongs to a specific workspace, pin it: `el-linear profile pin`. " +
62
+ "(silence this: EL_LINEAR_NO_PIN_HINT=1)");
63
+ return true;
64
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Repo-aware Linear-profile resolution (DEV-7277).
3
+ *
4
+ * el-linear supports multiple named profiles (one Linear workspace each). When
5
+ * a user works across several workspaces the machine-global `active-profile`
6
+ * marker is a foot-gun: whichever workspace was last `profile use`d becomes the
7
+ * default for EVERY repo, so a write lands in the wrong workspace.
8
+ *
9
+ * This module lets a repo PIN the profile it belongs to, read from two on-disk
10
+ * sources in precedence order:
11
+ * 1. Repo-root `.el-git.json`: `{ "linearProfile": "<name>" }`.
12
+ * 2. `${XDG_CONFIG_HOME:-~/.config}/el-git/linear-profiles.json`:
13
+ * `{ "profiles": { "owner/repo": "<name>", "owner/*": "<name>" } }` — keys
14
+ * are the origin remote's `owner/repo`; an exact key wins over the
15
+ * `owner/*` wildcard.
16
+ *
17
+ * ── SHARED CROSS-TOOL CONTRACT — DO NOT DRIFT ────────────────────────────────
18
+ * The file names, shapes, and precedence here are the SAME contract el-git
19
+ * (DEV-6465) and el-session (DEV-7131) already read/write via the internal
20
+ * `@enrichlayer/cli-package-info` module (`resolveRepoLinearProfile`). el-linear
21
+ * is a standalone MIT package published to public npm; cli-package-info is
22
+ * published only to an internal GitLab registry, so depending on it would break
23
+ * public installs. Instead this file re-implements the EXACT same formats and
24
+ * precedence. The filename stays `.el-git.json` (NOT a new `.el-linear` file) on
25
+ * purpose — it is the established shared location el-session already reads. If
26
+ * you change anything here, change the canonical impl at
27
+ * `tools/cli/cli-package-info/src/index.cjs` in lock-step or the tools silently
28
+ * disagree about which workspace a repo belongs to.
29
+ * ─────────────────────────────────────────────────────────────────────────────
30
+ *
31
+ * Trust note (inherited from the shared contract): the repo-local file is read
32
+ * from whatever repo you `cd` into, including an untrusted clone. Blast radius
33
+ * is bounded to selecting a profile NAME — worst case is a wrong-workspace
34
+ * lookup or a rejected name, never code execution. Callers still validate the
35
+ * resolved name with `isSafeProfileName` before it reaches any path join.
36
+ */
37
+ /** Repo-local pin file, at the git repo root. Shared with el-git/el-session. */
38
+ export declare const LINEAR_PROFILE_REPO_FILE = ".el-git.json";
39
+ /** Global pin table filename, under `<config>/el-git/`. */
40
+ export declare const LINEAR_PROFILE_USER_CONFIG = "linear-profiles.json";
41
+ /** Where the resolved pin came from. Mirrors the shared contract's `source`. */
42
+ export type RepoProfileSource = "repo-file" | "user-config";
43
+ export interface RepoLinearProfile {
44
+ profile: string;
45
+ source: RepoProfileSource;
46
+ }
47
+ /**
48
+ * Injected I/O seam so resolution is unit-testable without a real git repo or
49
+ * tmpdirs — mirrors the `ProfileFsOps` pattern in `paths.ts` and the `ops`
50
+ * injection in the canonical cli-package-info impl.
51
+ */
52
+ export interface RepoProfileOps {
53
+ existsSync: (p: string) => boolean;
54
+ readFileSync: (p: string) => string;
55
+ /** Git repo root for `cwd`, or null when not inside a repo. */
56
+ repoRoot: (cwd: string) => string | null;
57
+ /** Origin remote parsed to `owner/repo`, or null when unavailable. */
58
+ originFullpath: (cwd: string) => string | null;
59
+ }
60
+ export interface ResolveRepoProfileOptions {
61
+ ops?: Partial<RepoProfileOps>;
62
+ env?: NodeJS.ProcessEnv;
63
+ /** Called with the offending file path when a config JSON is malformed. */
64
+ onMalformedConfig?: (filePath: string) => void;
65
+ }
66
+ /** `${XDG_CONFIG_HOME:-~/.config}/el-git`. Env-injectable for tests. */
67
+ export declare function linearProfileConfigDir(env?: NodeJS.ProcessEnv): string;
68
+ export declare function globalPinTablePath(env?: NodeJS.ProcessEnv): string;
69
+ export declare function parseGitRemoteFullpath(url: string | null | undefined): string | null;
70
+ /** Real git/fs implementations. Overridden per-field in tests. */
71
+ export declare function defaultRepoProfileOps(): RepoProfileOps;
72
+ /**
73
+ * Resolve the profile a repo is pinned to, or null when it has no pin.
74
+ * Repo-local `.el-git.json` wins over the global `owner/repo` table; within the
75
+ * table an exact key wins over the `owner/*` wildcard.
76
+ */
77
+ export declare function resolveRepoLinearProfile(cwd?: string, opts?: ResolveRepoProfileOptions): RepoLinearProfile | null;
78
+ /**
79
+ * Merge `{ linearProfile: name }` into an existing `.el-git.json` body,
80
+ * preserving every other key. `existing` is the current file text (or null when
81
+ * the file is absent). Returns the new file text (2-space, trailing newline).
82
+ */
83
+ export declare function applyRepoLocalPin(existing: string | null, name: string): string;
84
+ /**
85
+ * Remove the `linearProfile` key from an existing `.el-git.json` body,
86
+ * preserving other keys. Returns the new file text, or null when the file
87
+ * should be deleted (it held only the pin / was absent / becomes empty).
88
+ */
89
+ export declare function removeRepoLocalPin(existing: string | null): string | null;
90
+ /**
91
+ * Merge `profiles[fullpath] = name` into the global pin table body, preserving
92
+ * every other entry. Returns the new file text (2-space, trailing newline).
93
+ */
94
+ export declare function applyGlobalPin(existing: string | null, fullpath: string, name: string): string;
95
+ /**
96
+ * Remove an exact `owner/repo` entry from the global pin table. Preserves
97
+ * wildcard entries and unrelated top-level keys. Returns null only when the
98
+ * file is absent or becomes empty after removal.
99
+ */
100
+ export declare function removeGlobalPin(existing: string | null, fullpath: string): string | null;