@enrichlayer/el-linear 1.40.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.
- package/README.md +5 -1
- package/dist/commands/issues.js +48 -3
- package/dist/commands/read-shortcut.js +12 -5
- package/dist/config/config.d.ts +12 -0
- package/dist/config/intake-decision-validation.d.ts +54 -0
- package/dist/config/intake-decision-validation.js +161 -0
- package/dist/utils/duplicate-detection.d.ts +49 -0
- package/dist/utils/duplicate-detection.js +58 -1
- package/dist/utils/extract-field.d.ts +2 -0
- package/dist/utils/extract-field.js +63 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -275,7 +275,11 @@ itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
|
|
|
275
275
|
[SOP-label parent gate](./docs/configuration.md#sop-label-parent-gate-validationsoplabelparentgate),
|
|
276
276
|
and an opt-in
|
|
277
277
|
[goal-completion gate](./docs/configuration.md#goal-completion-gate-validationgoalcompletiongate)
|
|
278
|
-
(requires a falsifiable "Done when" / acceptance-criteria section)
|
|
278
|
+
(requires a falsifiable "Done when" / acceptance-criteria section), plus an
|
|
279
|
+
opt-in
|
|
280
|
+
[intake-decision gate](./docs/configuration.md#intake-decision-gate-validationintakedecisiongate)
|
|
281
|
+
that requires need, value, ownership, and placement to be decided before issue
|
|
282
|
+
creation.
|
|
279
283
|
el-linear can record each gate's fire/override decision to a local JSONL file so
|
|
280
284
|
you can measure its **override-rate** and tell whether it's too aggressive. It is
|
|
281
285
|
**off by default** and writes nothing unless you opt in (e.g.
|
package/dist/commands/issues.js
CHANGED
|
@@ -2,13 +2,14 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { loadConfig } from "../config/config.js";
|
|
3
3
|
import { enrichProjectResolverError, enrichValidationErrors, } from "../config/error-enrichment.js";
|
|
4
4
|
import { evaluateGoalCompletion, formatGoalCompletionBlock, getGoalCompletionGateConfig, } from "../config/goal-completion-validation.js";
|
|
5
|
+
import { evaluateIntakeDecision, formatIntakeDecisionBlock, getIntakeDecisionGateConfig, } from "../config/intake-decision-validation.js";
|
|
5
6
|
import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
|
|
6
7
|
import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
|
|
7
8
|
import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
|
|
8
9
|
import { resolveDefaultStatus } from "../config/status-defaults.js";
|
|
9
10
|
import { enforceTerms } from "../config/term-enforcer.js";
|
|
10
11
|
import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
|
|
11
|
-
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";
|
|
12
13
|
import { createFileService } from "../utils/file-service.js";
|
|
13
14
|
import { applyFooter } from "../utils/footer.js";
|
|
14
15
|
import { emitGateEvent } from "../utils/gate-telemetry.js";
|
|
@@ -588,7 +589,12 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
|
588
589
|
// The gate would hard-block. Record the decision so `el-telemetry gates`
|
|
589
590
|
// can compute override-rate (DEV-4834): `overridden` when the user passed
|
|
590
591
|
// --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
|
|
591
|
-
|
|
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)) {
|
|
592
598
|
await emitGateEvent("el-linear", "issues create", {
|
|
593
599
|
...gateEvent,
|
|
594
600
|
outcome: "overridden",
|
|
@@ -762,6 +768,43 @@ async function enforceGoalCompletion(description, options) {
|
|
|
762
768
|
});
|
|
763
769
|
throw new Error(`Issue creation blocked: ${message}`);
|
|
764
770
|
}
|
|
771
|
+
/**
|
|
772
|
+
* DEV-6163: require the intake judgment before any create-time resolution or
|
|
773
|
+
* mutation. Unlike general field validation, this gate is not silently
|
|
774
|
+
* bypassed by `--skip-validation`; only its narrow, recorded override applies.
|
|
775
|
+
*/
|
|
776
|
+
async function enforceIntakeDecision(description, options) {
|
|
777
|
+
const { mode, headers } = getIntakeDecisionGateConfig();
|
|
778
|
+
if (mode === "off") {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const evaluation = evaluateIntakeDecision(description, headers);
|
|
782
|
+
if (evaluation.ok) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const gateEvent = { gate: "issues-create-intake-decision" };
|
|
786
|
+
if (options.allowMissingIntakeDecision) {
|
|
787
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
788
|
+
...gateEvent,
|
|
789
|
+
outcome: "overridden",
|
|
790
|
+
});
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const message = formatIntakeDecisionBlock({ evaluation, headers });
|
|
794
|
+
if (mode === "warn") {
|
|
795
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
796
|
+
...gateEvent,
|
|
797
|
+
outcome: "advisory",
|
|
798
|
+
});
|
|
799
|
+
outputWarning(message);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
803
|
+
...gateEvent,
|
|
804
|
+
outcome: "blocked",
|
|
805
|
+
});
|
|
806
|
+
throw new Error(`Issue creation blocked: ${message}`);
|
|
807
|
+
}
|
|
765
808
|
async function handleCreateIssue(title, options, command) {
|
|
766
809
|
const rootOpts = getRootOpts(command);
|
|
767
810
|
// DEV-5920 (cycle-2): resolve the description exactly ONCE, before anything
|
|
@@ -779,6 +822,7 @@ async function handleCreateIssue(title, options, command) {
|
|
|
779
822
|
// normalization) — re-resolving an inline copy would corrupt a file that
|
|
780
823
|
// intentionally contains backslash sequences.
|
|
781
824
|
const rawDescription = resolveDescription(options);
|
|
825
|
+
await enforceIntakeDecision(rawDescription ?? "", options);
|
|
782
826
|
const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts, rawDescription);
|
|
783
827
|
const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
|
|
784
828
|
const descriptionWithAttachments = buildDescriptionWithAttachments(rawDescription ?? "", uploadResults);
|
|
@@ -1401,10 +1445,11 @@ export function setupIssuesCommands(program) {
|
|
|
1401
1445
|
.option("--due-date <date>", "due date (YYYY-MM-DD)")
|
|
1402
1446
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1403
1447
|
.option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1404
|
-
.option("--skip-validation", "skip
|
|
1448
|
+
.option("--skip-validation", "skip general validation (labels, description, assignee, project, duplicate detection, SOP-parent gate, goal-completion gate); the intake-decision gate still requires its narrow override")
|
|
1405
1449
|
.option("--allow-duplicate", "silence the duplicate-detection hard block and create even if a near-identical issue already exists (DEV-5590: only matters at/above the hard-block threshold — below it the gate is advisory-only and never blocks)")
|
|
1406
1450
|
.option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
|
|
1407
1451
|
.option("--allow-vague-goal", 'skip the goal-completion gate and create even without a falsifiable "Done when" / acceptance-criteria section')
|
|
1452
|
+
.option("--allow-missing-intake-decision", "create without a complete intake decision when an accountable human approved the exception (recorded as a gate override)")
|
|
1408
1453
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1409
1454
|
.option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
|
|
1410
1455
|
.option("--no-footer", "skip the configured messageFooter for this issue")
|
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
83
|
-
? parseWithIncludes(
|
|
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).");
|
package/dist/config/config.d.ts
CHANGED
|
@@ -115,6 +115,18 @@ export interface ElLinearConfig {
|
|
|
115
115
|
* "Acceptance criteria", "Success criteria"]`.
|
|
116
116
|
*/
|
|
117
117
|
goalSectionHeaders?: string[];
|
|
118
|
+
/**
|
|
119
|
+
* OPT-IN explicit intake gate (DEV-6163). When `"warn"` or `"block"`,
|
|
120
|
+
* `issues create` requires an ordered `Intake decision` section recording
|
|
121
|
+
* why the work is needed, why it is worth doing, the existing/duplicate
|
|
122
|
+
* work checked, its canonical owner, concrete placement, and a `PROCEED`
|
|
123
|
+
* decision. Defaults to off for the
|
|
124
|
+
* open-source package. A narrow, recorded override is available through
|
|
125
|
+
* `--allow-missing-intake-decision`.
|
|
126
|
+
*/
|
|
127
|
+
intakeDecisionGate?: false | "warn" | "block";
|
|
128
|
+
/** Headers accepted for the intake section. Defaults to `["Intake decision"]`. */
|
|
129
|
+
intakeSectionHeaders?: string[];
|
|
118
130
|
};
|
|
119
131
|
/**
|
|
120
132
|
* Optional override for the Linear workspace URL key (the part after
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit issue-intake validation — DEV-6163.
|
|
3
|
+
*
|
|
4
|
+
* This gate does not try to decide whether work is worthwhile or where it
|
|
5
|
+
* belongs. It requires the author to record those judgments, in order, before
|
|
6
|
+
* `issues create` can mutate Linear. The deterministic part is completeness:
|
|
7
|
+
* needed, worth doing, existing/duplicate work checked, canonical owner,
|
|
8
|
+
* concrete placement, then a proceed decision.
|
|
9
|
+
*
|
|
10
|
+
* OPT-IN by design. el-linear is open source, so the gate is dormant unless a
|
|
11
|
+
* workspace config sets `validation.intakeDecisionGate` to `"warn"` or
|
|
12
|
+
* `"block"`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const DEFAULT_INTAKE_SECTION_HEADERS: string[];
|
|
15
|
+
export type IntakeDecisionGateMode = "off" | "warn" | "block";
|
|
16
|
+
export interface IntakeDecisionGateConfig {
|
|
17
|
+
mode: IntakeDecisionGateMode;
|
|
18
|
+
headers: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare function getIntakeDecisionGateConfig(): IntakeDecisionGateConfig;
|
|
21
|
+
export type IntakeDecisionEvaluation = {
|
|
22
|
+
ok: true;
|
|
23
|
+
header: string;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
reason: "no-section";
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
reason: "missing-field";
|
|
30
|
+
field: string;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
reason: "duplicate-field";
|
|
34
|
+
field: string;
|
|
35
|
+
} | {
|
|
36
|
+
ok: false;
|
|
37
|
+
reason: "out-of-order";
|
|
38
|
+
field: string;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
reason: "invalid-field";
|
|
42
|
+
field: string;
|
|
43
|
+
} | {
|
|
44
|
+
ok: false;
|
|
45
|
+
reason: "not-proceeding";
|
|
46
|
+
decision: string;
|
|
47
|
+
};
|
|
48
|
+
export declare function evaluateIntakeDecision(description: string, headers?: string[]): IntakeDecisionEvaluation;
|
|
49
|
+
export declare function formatIntakeDecisionBlock(opts: {
|
|
50
|
+
evaluation: Exclude<IntakeDecisionEvaluation, {
|
|
51
|
+
ok: true;
|
|
52
|
+
}>;
|
|
53
|
+
headers: string[];
|
|
54
|
+
}): string;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit issue-intake validation — DEV-6163.
|
|
3
|
+
*
|
|
4
|
+
* This gate does not try to decide whether work is worthwhile or where it
|
|
5
|
+
* belongs. It requires the author to record those judgments, in order, before
|
|
6
|
+
* `issues create` can mutate Linear. The deterministic part is completeness:
|
|
7
|
+
* needed, worth doing, existing/duplicate work checked, canonical owner,
|
|
8
|
+
* concrete placement, then a proceed decision.
|
|
9
|
+
*
|
|
10
|
+
* OPT-IN by design. el-linear is open source, so the gate is dormant unless a
|
|
11
|
+
* workspace config sets `validation.intakeDecisionGate` to `"warn"` or
|
|
12
|
+
* `"block"`.
|
|
13
|
+
*/
|
|
14
|
+
import { extractField, stripFencedCodeBlocks } from "../utils/extract-field.js";
|
|
15
|
+
import { loadConfig } from "./config.js";
|
|
16
|
+
export const DEFAULT_INTAKE_SECTION_HEADERS = ["Intake decision"];
|
|
17
|
+
export function getIntakeDecisionGateConfig() {
|
|
18
|
+
const validation = loadConfig().validation;
|
|
19
|
+
const raw = validation?.intakeDecisionGate;
|
|
20
|
+
const mode = validation?.enabled !== false && (raw === "warn" || raw === "block")
|
|
21
|
+
? raw
|
|
22
|
+
: "off";
|
|
23
|
+
const headers = validation?.intakeSectionHeaders &&
|
|
24
|
+
validation.intakeSectionHeaders.length > 0
|
|
25
|
+
? validation.intakeSectionHeaders
|
|
26
|
+
: DEFAULT_INTAKE_SECTION_HEADERS;
|
|
27
|
+
return { mode, headers };
|
|
28
|
+
}
|
|
29
|
+
const FIELD_DEFINITIONS = [
|
|
30
|
+
{ key: "needed", label: "Needed" },
|
|
31
|
+
{ key: "worth", label: "Worth doing" },
|
|
32
|
+
{ key: "existing", label: "Existing work" },
|
|
33
|
+
{ key: "owner", label: "Owner" },
|
|
34
|
+
{ key: "placement", label: "Placement" },
|
|
35
|
+
{ key: "decision", label: "Decision" },
|
|
36
|
+
];
|
|
37
|
+
const FIELD_LINE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)?(?:\*\*)?(Needed|Worth doing|Existing work|Owner|Placement|Decision)(?:\*\*)?\s*:\s*(.*?)\s*$/gim;
|
|
38
|
+
const PLACEHOLDER = /^(?:tbd|todo|unknown|n\/?a|none|unsure|not decided|-)\.?$/i;
|
|
39
|
+
const NON_SPECIFIC = /^(?:yes|no)$/i;
|
|
40
|
+
const AFFIRMATIVE_WITH_REASON = /^yes\s*(?:[-—:;,]|because)\s*(\S.{2,})$/i;
|
|
41
|
+
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";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function evaluateIntakeDecision(description, headers = DEFAULT_INTAKE_SECTION_HEADERS) {
|
|
58
|
+
let section = null;
|
|
59
|
+
let matchedHeader = "";
|
|
60
|
+
for (const header of headers) {
|
|
61
|
+
section = extractField(description, header);
|
|
62
|
+
if (section !== null) {
|
|
63
|
+
matchedHeader = header;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (section === null) {
|
|
68
|
+
return { ok: false, reason: "no-section" };
|
|
69
|
+
}
|
|
70
|
+
// A template/example fence is not an operative decision record. Remove all
|
|
71
|
+
// fenced examples before matching so copied guidance cannot satisfy intake.
|
|
72
|
+
const operativeSection = stripFencedCodeBlocks(section);
|
|
73
|
+
const values = new Map();
|
|
74
|
+
for (const match of operativeSection.matchAll(FIELD_LINE)) {
|
|
75
|
+
const key = normalizedKey(match[1] ?? "");
|
|
76
|
+
if (values.has(key)) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: "duplicate-field",
|
|
80
|
+
field: FIELD_DEFINITIONS.find((field) => field.key === key)?.label ?? key,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
values.set(key, { value: match[2]?.trim() ?? "", index: match.index });
|
|
84
|
+
}
|
|
85
|
+
let previousIndex = -1;
|
|
86
|
+
for (const definition of FIELD_DEFINITIONS) {
|
|
87
|
+
const entry = values.get(definition.key);
|
|
88
|
+
if (!entry) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: "missing-field",
|
|
92
|
+
field: definition.label,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (entry.index < previousIndex) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: "out-of-order",
|
|
99
|
+
field: definition.label,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
previousIndex = entry.index;
|
|
103
|
+
}
|
|
104
|
+
for (const key of ["needed", "worth"]) {
|
|
105
|
+
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)) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
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",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const decision = values.get("decision")?.value ?? "";
|
|
133
|
+
if (!/^proceed$/i.test(decision)) {
|
|
134
|
+
return { ok: false, reason: "not-proceeding", decision };
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, header: matchedHeader };
|
|
137
|
+
}
|
|
138
|
+
export function formatIntakeDecisionBlock(opts) {
|
|
139
|
+
const { evaluation } = opts;
|
|
140
|
+
let reason;
|
|
141
|
+
switch (evaluation.reason) {
|
|
142
|
+
case "no-section":
|
|
143
|
+
reason = `Issue description has no intake section (looked for: ${opts.headers.join(", ")}).`;
|
|
144
|
+
break;
|
|
145
|
+
case "missing-field":
|
|
146
|
+
reason = `The intake decision is missing the "${evaluation.field}" field.`;
|
|
147
|
+
break;
|
|
148
|
+
case "duplicate-field":
|
|
149
|
+
reason = `The intake decision repeats the "${evaluation.field}" field; record one unambiguous value.`;
|
|
150
|
+
break;
|
|
151
|
+
case "out-of-order":
|
|
152
|
+
reason = `The intake fields are out of order at "${evaluation.field}".`;
|
|
153
|
+
break;
|
|
154
|
+
case "invalid-field":
|
|
155
|
+
reason = `The intake field "${evaluation.field}" is empty, a placeholder, or lacks an explicit yes-and-reason judgment.`;
|
|
156
|
+
break;
|
|
157
|
+
case "not-proceeding":
|
|
158
|
+
reason = `The intake decision is "${evaluation.decision || "empty"}", not PROCEED.`;
|
|
159
|
+
}
|
|
160
|
+
return `${reason}\n\nRecord the decision before creating the issue, in this exact order:\n\n## ${opts.headers[0]}\n- Needed: Yes — <why this is needed>\n- Worth doing: Yes — <why the value exceeds the cost>\n- Existing work: <duplicate/search result and evidence>\n- Owner: <canonical owner or source of truth>\n- Placement: <team/project/repository/document path>\n- Decision: PROCEED\n\nIf an accountable human has approved an exceptional create, re-run with --allow-missing-intake-decision; that override is recorded.`;
|
|
161
|
+
}
|
|
@@ -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
|
|
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
|
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* Returns `null` when the field isn't found, so callers can distinguish
|
|
17
17
|
* "missing" from "empty body".
|
|
18
18
|
*/
|
|
19
|
+
/** Remove operative text inside CommonMark fenced code blocks, including an unclosed block through EOF. */
|
|
20
|
+
export declare function stripFencedCodeBlocks(body: string): string;
|
|
19
21
|
/**
|
|
20
22
|
* Multi-section variant of `extractField`. Extracts each requested section
|
|
21
23
|
* by name in one call and returns a `{section -> text|null}` map preserving
|
|
@@ -28,7 +28,50 @@ const BOLD_PSEUDO_HEADER_RE = /^\*\*([^*\n]+?)(?::\s*)?\*\*\s*$/;
|
|
|
28
28
|
// permitted as fence indent per the CommonMark spec). We toggle an inFence
|
|
29
29
|
// flag while scanning so section headers inside code samples don't
|
|
30
30
|
// terminate the real section.
|
|
31
|
-
const
|
|
31
|
+
const FENCE_DELIMITER_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
32
|
+
function fenceDelimiter(line) {
|
|
33
|
+
const match = line.match(FENCE_DELIMITER_RE);
|
|
34
|
+
const run = match?.[1] ?? "";
|
|
35
|
+
if (!run || new Set(run).size !== 1)
|
|
36
|
+
return null;
|
|
37
|
+
return { run, rest: match?.[2] ?? "" };
|
|
38
|
+
}
|
|
39
|
+
function openFence(line) {
|
|
40
|
+
const delimiter = fenceDelimiter(line);
|
|
41
|
+
if (!delimiter)
|
|
42
|
+
return null;
|
|
43
|
+
const marker = delimiter.run[0];
|
|
44
|
+
// CommonMark forbids backticks in the info string of a backtick fence.
|
|
45
|
+
if (marker === "`" && delimiter.rest.includes("`"))
|
|
46
|
+
return null;
|
|
47
|
+
return { marker, length: delimiter.run.length };
|
|
48
|
+
}
|
|
49
|
+
function closesFence(line, state) {
|
|
50
|
+
const delimiter = fenceDelimiter(line);
|
|
51
|
+
return (delimiter !== null &&
|
|
52
|
+
delimiter.run[0] === state.marker &&
|
|
53
|
+
delimiter.run.length >= state.length &&
|
|
54
|
+
delimiter.rest.trim().length === 0);
|
|
55
|
+
}
|
|
56
|
+
/** Remove operative text inside CommonMark fenced code blocks, including an unclosed block through EOF. */
|
|
57
|
+
export function stripFencedCodeBlocks(body) {
|
|
58
|
+
const out = [];
|
|
59
|
+
let fence = null;
|
|
60
|
+
for (const line of body.split("\n")) {
|
|
61
|
+
if (fence) {
|
|
62
|
+
if (closesFence(line, fence))
|
|
63
|
+
fence = null;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const opened = openFence(line);
|
|
67
|
+
if (opened) {
|
|
68
|
+
fence = opened;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out.push(line);
|
|
72
|
+
}
|
|
73
|
+
return out.join("\n");
|
|
74
|
+
}
|
|
32
75
|
function normalize(s) {
|
|
33
76
|
return s.toLowerCase().trim().replace(/\s+/g, " ");
|
|
34
77
|
}
|
|
@@ -86,15 +129,19 @@ export function extractField(body, fieldName) {
|
|
|
86
129
|
return null;
|
|
87
130
|
const target = normalize(fieldName);
|
|
88
131
|
const lines = body.split("\n");
|
|
89
|
-
let
|
|
132
|
+
let fence = null;
|
|
90
133
|
let startIdx = -1;
|
|
91
134
|
for (let i = 0; i < lines.length; i++) {
|
|
92
|
-
if (
|
|
93
|
-
|
|
135
|
+
if (fence) {
|
|
136
|
+
if (closesFence(lines[i], fence))
|
|
137
|
+
fence = null;
|
|
94
138
|
continue;
|
|
95
139
|
}
|
|
96
|
-
|
|
140
|
+
const opened = openFence(lines[i]);
|
|
141
|
+
if (opened) {
|
|
142
|
+
fence = opened;
|
|
97
143
|
continue;
|
|
144
|
+
}
|
|
98
145
|
const match = matchHeader(lines[i]);
|
|
99
146
|
if (!match)
|
|
100
147
|
continue;
|
|
@@ -106,14 +153,21 @@ export function extractField(body, fieldName) {
|
|
|
106
153
|
if (startIdx === -1)
|
|
107
154
|
return null;
|
|
108
155
|
const out = [];
|
|
109
|
-
let
|
|
156
|
+
let sectionFence = null;
|
|
110
157
|
for (let i = startIdx; i < lines.length; i++) {
|
|
111
|
-
if (
|
|
112
|
-
|
|
158
|
+
if (sectionFence) {
|
|
159
|
+
if (closesFence(lines[i], sectionFence))
|
|
160
|
+
sectionFence = null;
|
|
161
|
+
out.push(lines[i]);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const opened = openFence(lines[i]);
|
|
165
|
+
if (opened) {
|
|
166
|
+
sectionFence = opened;
|
|
113
167
|
out.push(lines[i]);
|
|
114
168
|
continue;
|
|
115
169
|
}
|
|
116
|
-
if (
|
|
170
|
+
if (matchHeader(lines[i]) !== null)
|
|
117
171
|
break;
|
|
118
172
|
out.push(lines[i]);
|
|
119
173
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
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",
|