@brainervirus/workit-core 0.5.6 → 0.6.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 +4 -17
- package/package.json +9 -9
- package/scripts/_shared/common.sh +18 -3
- package/scripts/install-opencode-plugin.sh +14 -9
- package/scripts/lib/config-dir.sh +26 -0
- package/scripts/sync-runtime.sh +4 -1
- package/src/core/branch.ts +143 -48
- package/src/core/changelog.ts +17 -14
- package/src/core/config-guard.ts +9 -2
- package/src/core/config.ts +95 -15
- package/src/core/detector.ts +22 -11
- package/src/core/docs-repo.ts +50 -15
- package/src/core/docs-validate.ts +163 -37
- package/src/core/flow-state.ts +6 -2
- package/src/core/gitignore.ts +11 -2
- package/src/core/handoff-context.ts +18 -5
- package/src/core/hygiene.ts +27 -5
- package/src/core/init.ts +86 -21
- package/src/core/parse-sections.ts +2 -2
- package/src/core/plan-tasks.ts +13 -3
- package/src/core/ports/youtrack-api.ts +3 -1
- package/src/core/ports/youtrack-config.ts +1 -3
- package/src/core/pr-create.ts +47 -15
- package/src/core/present.ts +11 -2
- package/src/core/reminder.ts +1 -2
- package/src/core/repo-tool.ts +4 -1
- package/src/core/rules.ts +10 -7
- package/src/core/scripts.ts +7 -2
- package/src/core/sdd.ts +11 -3
- package/src/core/templates.ts +14 -4
- package/src/core/vcs-config.ts +94 -36
- package/src/core/verify-parse.ts +4 -2
- package/src/core/workspaces.ts +2 -2
- package/src/core/youtrack.ts +233 -58
- package/src/core.ts +18 -3
- package/src/tools/docs-repo.ts +12 -3
- package/src/tools/flow.ts +24 -13
- package/src/tools/handoff.ts +28 -23
- package/src/tools/present.ts +14 -10
- package/src/tools/repo.ts +220 -87
- package/src/tools/sdd.ts +93 -66
- package/src/tools/youtrack.ts +119 -52
- package/templates/superpowers-doc-contract.md +1 -1
package/src/core/config.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
2
10
|
import os from "node:os";
|
|
3
11
|
import path from "node:path";
|
|
4
12
|
|
|
@@ -12,16 +20,69 @@ export type ToolkitConfig = {
|
|
|
12
20
|
};
|
|
13
21
|
|
|
14
22
|
export const PRESETS: Record<BranchPreset, { allowed: string[]; protected: string[] }> = {
|
|
15
|
-
gitflow: {
|
|
23
|
+
gitflow: {
|
|
24
|
+
allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"],
|
|
25
|
+
protected: ["main", "develop", "master", "prod", "production"],
|
|
26
|
+
},
|
|
16
27
|
"github-flow": { allowed: ["*"], protected: ["main"] },
|
|
17
28
|
"trunk-based": { allowed: ["*"], protected: ["main"] },
|
|
18
29
|
custom: { allowed: [], protected: [] },
|
|
19
30
|
};
|
|
20
31
|
|
|
21
|
-
export const
|
|
22
|
-
process.env.WORKFLOW_TOOLKIT_CONFIG
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
export const resolveConfigDir = (): string =>
|
|
33
|
+
process.env.WORKFLOW_TOOLKIT_CONFIG ??
|
|
34
|
+
process.env.WORKFLOW_TOOLKIT_CONFIG_DIR ??
|
|
35
|
+
path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "workit");
|
|
36
|
+
|
|
37
|
+
// One-time lazy migration from the legacy ~/.config/workflow-toolkit dir.
|
|
38
|
+
// migratedDir remembers the resolved dir already checked: configDir() is on
|
|
39
|
+
// hot paths, so subsequent calls are one string compare. Re-checking per
|
|
40
|
+
// unique dir also keeps tests with swapped env working.
|
|
41
|
+
// ponytail: cache keyed by dir value — an env override explicitly set to the
|
|
42
|
+
// default path caches before migration could trigger; only matters if that
|
|
43
|
+
// env is cleared mid-process (next unique dir value re-checks).
|
|
44
|
+
let migratedDir: string | null = null;
|
|
45
|
+
// A mid-loop copy failure leaves the new dir half-populated; keep retrying
|
|
46
|
+
// until a full pass succeeds instead of silently skipping the failed entry.
|
|
47
|
+
let migrationFailed = false;
|
|
48
|
+
|
|
49
|
+
export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
|
|
50
|
+
if (migratedDir === dir) return dir;
|
|
51
|
+
if (process.env.WORKFLOW_TOOLKIT_CONFIG || process.env.WORKFLOW_TOOLKIT_CONFIG_DIR) {
|
|
52
|
+
migratedDir = dir;
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
const legacy = path.join(
|
|
56
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
|
|
57
|
+
"workflow-toolkit",
|
|
58
|
+
);
|
|
59
|
+
if (!existsSync(legacy)) {
|
|
60
|
+
migratedDir = dir;
|
|
61
|
+
return dir;
|
|
62
|
+
}
|
|
63
|
+
if (!migrationFailed && existsSync(dir)) {
|
|
64
|
+
migratedDir = dir;
|
|
65
|
+
return dir;
|
|
66
|
+
}
|
|
67
|
+
migrationFailed = false;
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
for (const entry of readdirSync(legacy, { withFileTypes: true })) {
|
|
70
|
+
const src = path.join(legacy, entry.name);
|
|
71
|
+
const dest = path.join(dir, entry.name);
|
|
72
|
+
if (existsSync(dest)) continue;
|
|
73
|
+
try {
|
|
74
|
+
if (entry.isDirectory()) cpSync(src, dest, { recursive: true });
|
|
75
|
+
else if (entry.isFile()) copyFileSync(src, dest);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
migrationFailed = true;
|
|
78
|
+
console.warn(`[workit] config migration: failed to copy ${src}: ${(err as Error).message}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!migrationFailed) migratedDir = dir;
|
|
82
|
+
return dir;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const configDir = (): string => ensureConfigDir();
|
|
25
86
|
|
|
26
87
|
export const LOCALE_RE = /^[a-z]{2,3}(-[A-Z]{2})?$/;
|
|
27
88
|
|
|
@@ -29,11 +90,19 @@ const DEFAULTS: ToolkitConfig = {
|
|
|
29
90
|
locale: "en",
|
|
30
91
|
localeOptions: ["en", "es-CL", "es-MX", "es-AR", "pt-BR"],
|
|
31
92
|
timezone: "America/Santiago",
|
|
32
|
-
branchPolicy: {
|
|
93
|
+
branchPolicy: {
|
|
94
|
+
preset: "gitflow",
|
|
95
|
+
allowed: [...PRESETS.gitflow.allowed],
|
|
96
|
+
protected: [...PRESETS.gitflow.protected],
|
|
97
|
+
},
|
|
33
98
|
};
|
|
34
99
|
|
|
35
100
|
const readSafe = (p: string): string | null => {
|
|
36
|
-
try {
|
|
101
|
+
try {
|
|
102
|
+
return readFileSync(p, "utf8");
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
37
106
|
};
|
|
38
107
|
|
|
39
108
|
export const readConfig = (): ToolkitConfig => {
|
|
@@ -41,18 +110,26 @@ export const readConfig = (): ToolkitConfig => {
|
|
|
41
110
|
if (!raw) return DEFAULTS;
|
|
42
111
|
try {
|
|
43
112
|
const parsed = JSON.parse(raw) as Partial<ToolkitConfig>;
|
|
44
|
-
const locale = LOCALE_RE.test(String(parsed.locale ?? ""))
|
|
113
|
+
const locale = LOCALE_RE.test(String(parsed.locale ?? ""))
|
|
114
|
+
? (parsed.locale as string)
|
|
115
|
+
: DEFAULTS.locale;
|
|
45
116
|
const preset = (parsed.branchPolicy?.preset ?? "gitflow") as BranchPreset;
|
|
46
117
|
const presetOk = Object.hasOwn(PRESETS, preset) ? preset : "gitflow";
|
|
47
118
|
const presetDefs = PRESETS[presetOk];
|
|
48
119
|
return {
|
|
49
120
|
locale,
|
|
50
|
-
localeOptions: Array.isArray(parsed.localeOptions)
|
|
121
|
+
localeOptions: Array.isArray(parsed.localeOptions)
|
|
122
|
+
? parsed.localeOptions
|
|
123
|
+
: DEFAULTS.localeOptions,
|
|
51
124
|
timezone: parsed.timezone ?? DEFAULTS.timezone,
|
|
52
125
|
branchPolicy: {
|
|
53
126
|
preset: presetOk,
|
|
54
|
-
allowed: Array.isArray(parsed.branchPolicy?.allowed)
|
|
55
|
-
|
|
127
|
+
allowed: Array.isArray(parsed.branchPolicy?.allowed)
|
|
128
|
+
? parsed.branchPolicy.allowed
|
|
129
|
+
: presetDefs.allowed,
|
|
130
|
+
protected: Array.isArray(parsed.branchPolicy?.protected)
|
|
131
|
+
? parsed.branchPolicy.protected
|
|
132
|
+
: presetDefs.protected,
|
|
56
133
|
},
|
|
57
134
|
};
|
|
58
135
|
} catch {
|
|
@@ -66,8 +143,11 @@ export const writeConfig = (config: ToolkitConfig): void => {
|
|
|
66
143
|
writeFileSync(path.join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
67
144
|
};
|
|
68
145
|
|
|
69
|
-
export const resolveBranchPolicy = (
|
|
70
|
-
|
|
71
|
-
|
|
146
|
+
export const resolveBranchPolicy = (
|
|
147
|
+
config: ToolkitConfig,
|
|
148
|
+
): { allowed: RegExp[]; protected: Set<string> } => {
|
|
149
|
+
const allowed = config.branchPolicy.allowed.map(
|
|
150
|
+
(p) => new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`, "i"),
|
|
151
|
+
);
|
|
72
152
|
return { allowed, protected: new Set(config.branchPolicy.protected.map((p) => p.toLowerCase())) };
|
|
73
153
|
};
|
package/src/core/detector.ts
CHANGED
|
@@ -5,8 +5,7 @@ import { parseTasksFromPlan } from "./docs-validate";
|
|
|
5
5
|
|
|
6
6
|
export type Detection = { choices: string[]; pattern: "alpha" | "numeric" } | null;
|
|
7
7
|
|
|
8
|
-
export const detectConfigGapError = (text: string): boolean =>
|
|
9
|
-
text.includes(CONFIG_GAP_MARKER);
|
|
8
|
+
export const detectConfigGapError = (text: string): boolean => text.includes(CONFIG_GAP_MARKER);
|
|
10
9
|
|
|
11
10
|
// Enforcement-rail detectors: case-insensitive word-boundary heuristics.
|
|
12
11
|
// Conservative bias (D-03): require 1+ signal word AND 0 evidence words;
|
|
@@ -86,20 +85,27 @@ export const detectInstructionOption = (questions: unknown): boolean => {
|
|
|
86
85
|
// Labeled blocks are stripped first so their plain closing fence (```) can't match.
|
|
87
86
|
export const detectRawDocDelivery = (text: string): boolean =>
|
|
88
87
|
/^```\s*$/m.test(text.replace(/```\S[^\n]*\r?\n[\s\S]*?```/g, "")) &&
|
|
89
|
-
(text.includes("# Spec") ||
|
|
90
|
-
text.includes("
|
|
88
|
+
(text.includes("# Spec") ||
|
|
89
|
+
text.includes("# Plan") ||
|
|
90
|
+
text.includes("**Spec:**") ||
|
|
91
|
+
text.includes("**Branch:**"));
|
|
91
92
|
|
|
92
93
|
// Interrogative gate: a literal question mark OR explicit interrogative phrases.
|
|
93
94
|
// Plain "I want to confirm..." or "the script which runs" must NOT match.
|
|
94
|
-
const INTERROGATIVE =
|
|
95
|
+
const INTERROGATIVE =
|
|
96
|
+
/[?¿]|which\s+one|choose\s+(?:one|between|among)|do\s+you\s+(?:want|prefer)|want\s+me\s+to/i;
|
|
95
97
|
|
|
96
98
|
export const detectProseChoices = (text: string): Detection => {
|
|
97
99
|
if (!INTERROGATIVE.test(text)) return null;
|
|
98
100
|
|
|
99
|
-
const lines = text
|
|
101
|
+
const lines = text
|
|
102
|
+
.split("\n")
|
|
103
|
+
.map((l) => l.trim())
|
|
104
|
+
.filter(Boolean);
|
|
100
105
|
|
|
101
|
-
const alphaAll = [...text.matchAll(/([a-dA-D])[.)]\s+([^\n]*?)(?=\s+[a-dA-D][.)]\s|$)/g)]
|
|
102
|
-
|
|
106
|
+
const alphaAll = [...text.matchAll(/([a-dA-D])[.)]\s+([^\n]*?)(?=\s+[a-dA-D][.)]\s|$)/g)].map(
|
|
107
|
+
(m) => ({ letter: m[1].toLowerCase(), choice: m[2].trim() }),
|
|
108
|
+
);
|
|
103
109
|
const alphaLines = lines
|
|
104
110
|
.map((l) => /^([a-dA-D])[.)]\s+(.+)$/.exec(l))
|
|
105
111
|
.filter((m): m is RegExpExecArray => Boolean(m))
|
|
@@ -114,8 +120,10 @@ export const detectProseChoices = (text: string): Detection => {
|
|
|
114
120
|
}
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
const numericAll = [...text.matchAll(/(\d+)[.)]\s+([^\n]*?)(?=\s+\d+[.)]\s|$)/g)]
|
|
118
|
-
|
|
123
|
+
const numericAll = [...text.matchAll(/(\d+)[.)]\s+([^\n]*?)(?=\s+\d+[.)]\s|$)/g)].map((m) => ({
|
|
124
|
+
num: Number(m[1]),
|
|
125
|
+
choice: m[2].trim(),
|
|
126
|
+
}));
|
|
119
127
|
const numericLines = lines
|
|
120
128
|
.map((l) => /^(\d+)[.)]\s+(.+)$/.exec(l))
|
|
121
129
|
.filter((m): m is RegExpExecArray => Boolean(m))
|
|
@@ -137,7 +145,10 @@ const stripFences = (text: string): string => {
|
|
|
137
145
|
const out: string[] = [];
|
|
138
146
|
let inFence = false;
|
|
139
147
|
for (const line of lines) {
|
|
140
|
-
if (line.startsWith("```")) {
|
|
148
|
+
if (line.startsWith("```")) {
|
|
149
|
+
inFence = !inFence;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
141
152
|
if (!inFence) out.push(line);
|
|
142
153
|
}
|
|
143
154
|
return out.join("\n");
|
package/src/core/docs-repo.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
4
|
+
import { configDir } from "./config";
|
|
5
5
|
|
|
6
6
|
const configPath = () =>
|
|
7
|
-
process.env.WORKFLOW_DOCS_REPO_CONFIG
|
|
8
|
-
?? path.join(os.homedir(), ".config", "workflow-toolkit", "docs-repo.json");
|
|
7
|
+
process.env.WORKFLOW_DOCS_REPO_CONFIG ?? path.join(configDir(), "docs-repo.json");
|
|
9
8
|
|
|
10
9
|
export const readDocsRepoConfig = (): { path: string } | null => {
|
|
11
10
|
try {
|
|
@@ -25,7 +24,8 @@ export const writeDocsRepoConfig = (docsPath: string): void => {
|
|
|
25
24
|
export const docsRepoPath = (): string | null => readDocsRepoConfig()?.path ?? null;
|
|
26
25
|
|
|
27
26
|
export const validateDocsRepo = (docsPath: string): { ok: true } | { ok: false; error: string } => {
|
|
28
|
-
if (!existsSync(docsPath))
|
|
27
|
+
if (!existsSync(docsPath))
|
|
28
|
+
return { ok: false, error: `docs repo path does not exist: ${docsPath}` };
|
|
29
29
|
try {
|
|
30
30
|
execFileSync("git", ["-C", docsPath, "rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
|
|
31
31
|
} catch {
|
|
@@ -49,7 +49,10 @@ export const linkDocsRepo = (
|
|
|
49
49
|
|
|
50
50
|
export const listSpecs = (
|
|
51
51
|
workspaceRoot: string,
|
|
52
|
-
): {
|
|
52
|
+
): {
|
|
53
|
+
docs_repo: string | null;
|
|
54
|
+
specs: { slug: string; spec: string; promoted: boolean; target: string | null }[];
|
|
55
|
+
} => {
|
|
53
56
|
const repoPath = docsRepoPath();
|
|
54
57
|
const specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] = [];
|
|
55
58
|
const docsDir = path.join(workspaceRoot, "docs");
|
|
@@ -63,7 +66,9 @@ export const listSpecs = (
|
|
|
63
66
|
if (repoPath) {
|
|
64
67
|
const featuresDir = path.join(repoPath, "features");
|
|
65
68
|
if (existsSync(featuresDir)) {
|
|
66
|
-
const match = readdirSync(featuresDir).find((d) =>
|
|
69
|
+
const match = readdirSync(featuresDir).find((d) =>
|
|
70
|
+
new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d),
|
|
71
|
+
);
|
|
67
72
|
if (match) {
|
|
68
73
|
promoted = true;
|
|
69
74
|
target = path.join(repoPath, "features", match);
|
|
@@ -84,13 +89,20 @@ const monthPrefix = () => {
|
|
|
84
89
|
};
|
|
85
90
|
|
|
86
91
|
const readSafe = (p: string): string | null => {
|
|
87
|
-
try {
|
|
92
|
+
try {
|
|
93
|
+
return readFileSync(p, "utf8");
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
88
97
|
};
|
|
89
98
|
|
|
90
99
|
const specSummary = (specText: string): string => {
|
|
91
|
-
const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n##
|
|
100
|
+
const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |Z)/);
|
|
92
101
|
if (!contextMatch) return "";
|
|
93
|
-
const first = contextMatch[1]
|
|
102
|
+
const first = contextMatch[1]
|
|
103
|
+
.trim()
|
|
104
|
+
.split("\n")
|
|
105
|
+
.find((l) => l.trim() && !l.startsWith("<!--"));
|
|
94
106
|
return (first ?? "").trim();
|
|
95
107
|
};
|
|
96
108
|
|
|
@@ -105,7 +117,8 @@ export const promoteSpec = (
|
|
|
105
117
|
workspaceRoot: string,
|
|
106
118
|
slug: string,
|
|
107
119
|
opts: { confirmed: boolean; force?: boolean },
|
|
108
|
-
):
|
|
120
|
+
):
|
|
121
|
+
| { ok: true; target_dir: string; files: string[]; index_updated: boolean }
|
|
109
122
|
| { ok: false; error: string; findings?: unknown[] } => {
|
|
110
123
|
if (!opts.confirmed) return { ok: false, error: "confirmed: true required" };
|
|
111
124
|
if (!SLUG_RE.test(slug)) return { ok: false, error: `invalid slug: ${JSON.stringify(slug)}` };
|
|
@@ -121,14 +134,22 @@ export const promoteSpec = (
|
|
|
121
134
|
|
|
122
135
|
const planText = readSafe(path.join(workspaceRoot, planRel));
|
|
123
136
|
if (planText !== null) {
|
|
124
|
-
const validated = docsValidate({
|
|
137
|
+
const validated = docsValidate({
|
|
138
|
+
spec_path: specRel,
|
|
139
|
+
plan_path: planRel,
|
|
140
|
+
workspace_root: workspaceRoot,
|
|
141
|
+
});
|
|
125
142
|
if (validated.ok === false) return { ok: false, error: validated.error };
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
const findings = qualitySpec(specText);
|
|
129
146
|
const hardFindings = findings.filter((f) => f.severity === "hard");
|
|
130
147
|
if (hardFindings.length > 0 && !opts.force) {
|
|
131
|
-
return {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
error: "spec has hard quality findings; pass force: true to override",
|
|
151
|
+
findings,
|
|
152
|
+
};
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
// SDD working state must be gitignored before promotion
|
|
@@ -136,9 +157,21 @@ export const promoteSpec = (
|
|
|
136
157
|
const sddDir = path.join(workspaceRoot, "docs", slug, "sdd");
|
|
137
158
|
if (existsSync(sddDir)) {
|
|
138
159
|
try {
|
|
139
|
-
execFileSync(
|
|
160
|
+
execFileSync(
|
|
161
|
+
"git",
|
|
162
|
+
[
|
|
163
|
+
"-C",
|
|
164
|
+
workspaceRoot,
|
|
165
|
+
"check-ignore",
|
|
166
|
+
path.posix.join("docs", slug, "sdd", "progress.md"),
|
|
167
|
+
],
|
|
168
|
+
{ stdio: "pipe" },
|
|
169
|
+
);
|
|
140
170
|
} catch {
|
|
141
|
-
return {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
error: `docs/${slug}/sdd/ is not gitignored — add 'docs/*/sdd/' to .gitignore or pass force: true`,
|
|
174
|
+
};
|
|
142
175
|
}
|
|
143
176
|
}
|
|
144
177
|
}
|
|
@@ -179,7 +212,9 @@ ${planText !== null ? "| [plan.md](./plan.md) | Plan de implementación |\n" : "
|
|
|
179
212
|
files.push("README.md");
|
|
180
213
|
|
|
181
214
|
const indexPath = path.join(repoPath, "features", "README.md");
|
|
182
|
-
const indexText =
|
|
215
|
+
const indexText =
|
|
216
|
+
readSafe(indexPath) ??
|
|
217
|
+
`# Features\n\nEspecificaciones y planes por feature.\n\n## Features documentadas\n\n| Feature | Repos afectados | Estado |\n| --- | --- | --- |\n`;
|
|
183
218
|
const row = `| [${slug}](./${prefix}-${slug}/) | ${specRepos(specText)} | Spec en revisión |`;
|
|
184
219
|
const rowRe = new RegExp(`^\\| \\[${slug}\\]\\([^)]*\\) \\|.*$`, "m");
|
|
185
220
|
let newIndex: string;
|