@slope-dev/cli 0.3.3 → 0.4.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/dist/commands/auto-card.js +128 -0
- package/dist/commands/init.js +106 -36
- package/dist/commands/next.js +27 -0
- package/dist/index.js +18 -1
- package/dist/loader.js +19 -0
- package/package.json +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { buildScorecard, computeSlope } from '@slope-dev/core';
|
|
5
|
+
import { loadConfig } from '../config.js';
|
|
6
|
+
function parseArgs(args) {
|
|
7
|
+
const result = {};
|
|
8
|
+
for (const arg of args) {
|
|
9
|
+
const match = arg.match(/^--(\w[\w-]*)=(.+)$/);
|
|
10
|
+
if (match)
|
|
11
|
+
result[match[1]] = match[2];
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
function git(cmd) {
|
|
16
|
+
try {
|
|
17
|
+
return execSync(`git ${cmd}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function getCommits(since, branch) {
|
|
24
|
+
const ref = branch ?? 'HEAD';
|
|
25
|
+
const sinceArg = since ? `--since="${since}"` : '';
|
|
26
|
+
const log = git(`log ${ref} ${sinceArg} --format="%H|||%s" --no-merges`);
|
|
27
|
+
if (!log)
|
|
28
|
+
return [];
|
|
29
|
+
return log.split('\n').filter(Boolean).map((line) => {
|
|
30
|
+
const [hash, subject] = line.split('|||');
|
|
31
|
+
const stat = git(`diff-tree --no-commit-id --name-only -r ${hash}`);
|
|
32
|
+
return {
|
|
33
|
+
hash,
|
|
34
|
+
subject: subject ?? '',
|
|
35
|
+
filesChanged: stat ? stat.split('\n').filter(Boolean).length : 0,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function inferTicketKey(subject, index, sprintNumber) {
|
|
40
|
+
const ticketMatch = subject.match(/\b[A-Z]+-\d+\b/) ?? subject.match(/\bS\d+-\d+\b/i);
|
|
41
|
+
if (ticketMatch)
|
|
42
|
+
return ticketMatch[0];
|
|
43
|
+
return `S${sprintNumber}-${index + 1}`;
|
|
44
|
+
}
|
|
45
|
+
function inferClub(filesChanged) {
|
|
46
|
+
if (filesChanged >= 10)
|
|
47
|
+
return 'driver';
|
|
48
|
+
if (filesChanged >= 5)
|
|
49
|
+
return 'long_iron';
|
|
50
|
+
if (filesChanged >= 2)
|
|
51
|
+
return 'short_iron';
|
|
52
|
+
return 'wedge';
|
|
53
|
+
}
|
|
54
|
+
function inferSlopeFactors(commits) {
|
|
55
|
+
const factors = [];
|
|
56
|
+
const allSubjects = commits.map((c) => c.subject.toLowerCase()).join(' ');
|
|
57
|
+
if (allSubjects.includes('migration') || allSubjects.includes('schema'))
|
|
58
|
+
factors.push('schema_migration');
|
|
59
|
+
if (allSubjects.includes('deploy') || allSubjects.includes('ci') || allSubjects.includes('docker'))
|
|
60
|
+
factors.push('deployment');
|
|
61
|
+
if (commits.some((c) => c.filesChanged > 15))
|
|
62
|
+
factors.push('large_scope');
|
|
63
|
+
if (allSubjects.includes('refactor'))
|
|
64
|
+
factors.push('refactor');
|
|
65
|
+
if (new Set(commits.flatMap((c) => c.subject.match(/packages\/\w+/g) ?? [])).size > 1)
|
|
66
|
+
factors.push('cross_package');
|
|
67
|
+
return factors;
|
|
68
|
+
}
|
|
69
|
+
export function autoCardCommand(args) {
|
|
70
|
+
const opts = parseArgs(args);
|
|
71
|
+
const config = loadConfig();
|
|
72
|
+
const sprintNumber = parseInt(opts.sprint ?? '', 10);
|
|
73
|
+
if (!sprintNumber) {
|
|
74
|
+
console.error('\nUsage: slope auto-card --sprint=<N> [--since=<date>] [--branch=<ref>] [--theme=<text>] [--dry-run]\n');
|
|
75
|
+
console.error(' --sprint Sprint number (required)');
|
|
76
|
+
console.error(' --since Git log start date, e.g. "2026-02-20" (optional)');
|
|
77
|
+
console.error(' --branch Git ref to scan (default: HEAD)');
|
|
78
|
+
console.error(' --theme Sprint theme (default: auto-generated)');
|
|
79
|
+
console.error(' --dry-run Print scorecard JSON without writing to disk\n');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
const commits = getCommits(opts.since, opts.branch);
|
|
83
|
+
if (commits.length === 0) {
|
|
84
|
+
console.error('\nNo commits found. Try specifying --since or --branch.\n');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const shots = commits.map((commit, i) => ({
|
|
88
|
+
ticket_key: inferTicketKey(commit.subject, i, sprintNumber),
|
|
89
|
+
title: commit.subject,
|
|
90
|
+
club: inferClub(commit.filesChanged),
|
|
91
|
+
result: 'in_the_hole',
|
|
92
|
+
hazards: [],
|
|
93
|
+
notes: `${commit.filesChanged} files changed`,
|
|
94
|
+
}));
|
|
95
|
+
const slopeFactors = inferSlopeFactors(commits);
|
|
96
|
+
const theme = opts.theme ?? `Sprint ${sprintNumber}`;
|
|
97
|
+
const card = buildScorecard({
|
|
98
|
+
sprint_number: sprintNumber,
|
|
99
|
+
theme,
|
|
100
|
+
par: shots.length <= 2 ? 3 : shots.length <= 4 ? 4 : 5,
|
|
101
|
+
slope: computeSlope(slopeFactors),
|
|
102
|
+
date: new Date().toISOString().split('T')[0],
|
|
103
|
+
shots,
|
|
104
|
+
});
|
|
105
|
+
const output = {
|
|
106
|
+
...card,
|
|
107
|
+
slope_factors: slopeFactors,
|
|
108
|
+
_auto_generated: true,
|
|
109
|
+
_commits: commits.length,
|
|
110
|
+
_note: 'Auto-generated by `slope auto-card`. Review shot results and adjust before filing.',
|
|
111
|
+
};
|
|
112
|
+
const json = JSON.stringify(output, null, 2);
|
|
113
|
+
if (args.includes('--dry-run')) {
|
|
114
|
+
console.log('\n' + json + '\n');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const cwd = process.cwd();
|
|
118
|
+
const outPath = join(cwd, config.scorecardDir, `sprint-${sprintNumber}.json`);
|
|
119
|
+
if (existsSync(outPath)) {
|
|
120
|
+
console.error(`\n ${outPath} already exists. Use --dry-run to preview, then write manually.\n`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
writeFileSync(outPath, json + '\n');
|
|
124
|
+
console.log(`\n Written to ${outPath}`);
|
|
125
|
+
console.log(` ${commits.length} commits → ${shots.length} shots`);
|
|
126
|
+
console.log(` Par: ${card.par} | Slope: ${card.slope} (${slopeFactors.join(', ') || 'none'})`);
|
|
127
|
+
console.log(`\n ⚠ Review shot results before filing — all default to in_the_hole.\n`);
|
|
128
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, existsSync, cpSync } from 'node:fs';
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync, cpSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { createConfig } from '../config.js';
|
|
@@ -64,70 +64,140 @@ const EXAMPLE_COMMON_ISSUES = {
|
|
|
64
64
|
},
|
|
65
65
|
],
|
|
66
66
|
};
|
|
67
|
+
function detectProvider(args) {
|
|
68
|
+
if (args.includes('--claude-code'))
|
|
69
|
+
return 'claude-code';
|
|
70
|
+
if (args.includes('--cursor'))
|
|
71
|
+
return 'cursor';
|
|
72
|
+
if (args.includes('--generic'))
|
|
73
|
+
return 'generic';
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function getTemplatesRoot() {
|
|
77
|
+
return join(__dirname, '..', '..', '..', '..', 'templates');
|
|
78
|
+
}
|
|
79
|
+
function installClaudeCodeTemplates(cwd) {
|
|
80
|
+
const templatesRoot = join(getTemplatesRoot(), 'claude-code');
|
|
81
|
+
const rulesDir = join(cwd, '.claude', 'rules');
|
|
82
|
+
const hooksDir = join(cwd, '.claude', 'hooks');
|
|
83
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
84
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
85
|
+
const ruleFiles = ['sprint-checklist.md', 'commit-discipline.md', 'review-loop.md'];
|
|
86
|
+
for (const file of ruleFiles) {
|
|
87
|
+
const src = join(templatesRoot, 'rules', file);
|
|
88
|
+
const dest = join(rulesDir, file);
|
|
89
|
+
if (existsSync(src) && !existsSync(dest)) {
|
|
90
|
+
cpSync(src, dest);
|
|
91
|
+
console.log(` Created ${dest}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const hookFiles = ['pre-merge-check.sh'];
|
|
95
|
+
for (const file of hookFiles) {
|
|
96
|
+
const src = join(templatesRoot, 'hooks', file);
|
|
97
|
+
const dest = join(hooksDir, file);
|
|
98
|
+
if (existsSync(src) && !existsSync(dest)) {
|
|
99
|
+
cpSync(src, dest);
|
|
100
|
+
console.log(` Created ${dest}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
console.log('\n Claude Code templates installed to .claude/rules/ and .claude/hooks/');
|
|
104
|
+
}
|
|
105
|
+
function installCursorTemplates(cwd) {
|
|
106
|
+
const templatesRoot = join(getTemplatesRoot(), 'cursor', 'rules');
|
|
107
|
+
const rulesDir = join(cwd, '.cursor', 'rules');
|
|
108
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
109
|
+
try {
|
|
110
|
+
const files = readdirSync(templatesRoot).filter((f) => f.endsWith('.mdc'));
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
const src = join(templatesRoot, file);
|
|
113
|
+
const dest = join(rulesDir, file);
|
|
114
|
+
if (!existsSync(dest)) {
|
|
115
|
+
cpSync(src, dest);
|
|
116
|
+
console.log(` Created ${dest}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
console.error(' Warning: Could not find Cursor rule templates');
|
|
122
|
+
}
|
|
123
|
+
console.log('\n Cursor rules installed to .cursor/rules/');
|
|
124
|
+
}
|
|
125
|
+
function installGenericTemplates(cwd) {
|
|
126
|
+
const templatesRoot = join(getTemplatesRoot(), 'generic');
|
|
127
|
+
const dest = join(cwd, 'SLOPE-CHECKLIST.md');
|
|
128
|
+
const src = join(templatesRoot, 'SLOPE-CHECKLIST.md');
|
|
129
|
+
if (existsSync(src) && !existsSync(dest)) {
|
|
130
|
+
cpSync(src, dest);
|
|
131
|
+
console.log(` Created ${dest}`);
|
|
132
|
+
}
|
|
133
|
+
console.log('\n Generic SLOPE checklist installed.');
|
|
134
|
+
}
|
|
135
|
+
const SLOPE_MCP_ENTRY = {
|
|
136
|
+
command: 'npx',
|
|
137
|
+
args: ['@slope-dev/mcp-tools'],
|
|
138
|
+
};
|
|
139
|
+
function installCursorMcpConfig(cwd) {
|
|
140
|
+
const mcpPath = join(cwd, '.cursor', 'mcp.json');
|
|
141
|
+
let config = {};
|
|
142
|
+
if (existsSync(mcpPath)) {
|
|
143
|
+
try {
|
|
144
|
+
const raw = readFileSync(mcpPath, 'utf8');
|
|
145
|
+
config = JSON.parse(raw);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
config = {};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!config.mcpServers)
|
|
152
|
+
config.mcpServers = {};
|
|
153
|
+
config.mcpServers.slope = SLOPE_MCP_ENTRY;
|
|
154
|
+
mkdirSync(join(cwd, '.cursor'), { recursive: true });
|
|
155
|
+
writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n');
|
|
156
|
+
console.log(` Created/updated ${mcpPath} (slope MCP server)`);
|
|
157
|
+
}
|
|
67
158
|
export function initCommand(args) {
|
|
68
159
|
const cwd = process.cwd();
|
|
69
|
-
const
|
|
70
|
-
// Create .slope directory and config
|
|
160
|
+
const provider = detectProvider(args);
|
|
71
161
|
const configPath = createConfig(cwd);
|
|
72
162
|
console.log(` Created ${configPath}`);
|
|
73
|
-
// Create scorecard directory
|
|
74
163
|
const scorecardDir = join(cwd, 'docs', 'retros');
|
|
75
164
|
if (!existsSync(scorecardDir)) {
|
|
76
165
|
mkdirSync(scorecardDir, { recursive: true });
|
|
77
166
|
console.log(` Created ${scorecardDir}/`);
|
|
78
167
|
}
|
|
79
|
-
// Write example scorecard
|
|
80
168
|
const examplePath = join(scorecardDir, 'sprint-1.json');
|
|
81
169
|
if (!existsSync(examplePath)) {
|
|
82
170
|
writeFileSync(examplePath, JSON.stringify(EXAMPLE_SCORECARD, null, 2) + '\n');
|
|
83
171
|
console.log(` Created ${examplePath}`);
|
|
84
172
|
}
|
|
85
|
-
// Write example common-issues.json
|
|
86
173
|
const commonIssuesPath = join(cwd, '.slope', 'common-issues.json');
|
|
87
174
|
if (!existsSync(commonIssuesPath)) {
|
|
88
175
|
writeFileSync(commonIssuesPath, JSON.stringify(EXAMPLE_COMMON_ISSUES, null, 2) + '\n');
|
|
89
176
|
console.log(` Created ${commonIssuesPath}`);
|
|
90
177
|
}
|
|
91
|
-
// Write example sessions.json
|
|
92
178
|
const sessionsPath = join(cwd, '.slope', 'sessions.json');
|
|
93
179
|
if (!existsSync(sessionsPath)) {
|
|
94
180
|
writeFileSync(sessionsPath, JSON.stringify({ sessions: [] }, null, 2) + '\n');
|
|
95
181
|
console.log(` Created ${sessionsPath}`);
|
|
96
182
|
}
|
|
97
|
-
// Write empty claims.json
|
|
98
183
|
const claimsPath = join(cwd, '.slope', 'claims.json');
|
|
99
184
|
if (!existsSync(claimsPath)) {
|
|
100
185
|
writeFileSync(claimsPath, JSON.stringify({ claims: [] }, null, 2) + '\n');
|
|
101
186
|
console.log(` Created ${claimsPath}`);
|
|
102
187
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
cpSync(src, dest);
|
|
117
|
-
console.log(` Created ${dest}`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
// Copy hook templates
|
|
121
|
-
const hookFiles = ['pre-merge-check.sh'];
|
|
122
|
-
for (const file of hookFiles) {
|
|
123
|
-
const src = join(templatesRoot, 'hooks', file);
|
|
124
|
-
const dest = join(hooksDir, file);
|
|
125
|
-
if (existsSync(src) && !existsSync(dest)) {
|
|
126
|
-
cpSync(src, dest);
|
|
127
|
-
console.log(` Created ${dest}`);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
console.log('\n Claude Code templates installed to .claude/rules/ and .claude/hooks/');
|
|
188
|
+
switch (provider) {
|
|
189
|
+
case 'claude-code':
|
|
190
|
+
installClaudeCodeTemplates(cwd);
|
|
191
|
+
break;
|
|
192
|
+
case 'cursor':
|
|
193
|
+
installCursorTemplates(cwd);
|
|
194
|
+
installCursorMcpConfig(cwd);
|
|
195
|
+
break;
|
|
196
|
+
case 'generic':
|
|
197
|
+
installGenericTemplates(cwd);
|
|
198
|
+
break;
|
|
199
|
+
default:
|
|
200
|
+
break;
|
|
131
201
|
}
|
|
132
202
|
console.log('\nSLOPE initialized. Try:');
|
|
133
203
|
console.log(' slope card');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js';
|
|
2
|
+
import { resolveCurrentSprint, detectLatestSprint } from '../loader.js';
|
|
3
|
+
export function nextCommand() {
|
|
4
|
+
const config = loadConfig();
|
|
5
|
+
const cwd = process.cwd();
|
|
6
|
+
const latest = detectLatestSprint(config, cwd);
|
|
7
|
+
const next = resolveCurrentSprint(config, cwd);
|
|
8
|
+
console.log('');
|
|
9
|
+
if (latest === 0) {
|
|
10
|
+
console.log(' No scorecards found. Next sprint: S1');
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
console.log(` Latest scorecard: S${latest}`);
|
|
14
|
+
console.log(` Next sprint: S${next}`);
|
|
15
|
+
}
|
|
16
|
+
if (config.currentSprint) {
|
|
17
|
+
console.log(` (set explicitly in .slope/config.json)`);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
console.log(` (auto-detected from scorecards)`);
|
|
21
|
+
}
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log(' Quick start:');
|
|
24
|
+
console.log(` slope briefing --sprint=${next}`);
|
|
25
|
+
console.log(` slope auto-card --sprint=${next} --since="$(date -d yesterday +%Y-%m-%d)"`);
|
|
26
|
+
console.log('');
|
|
27
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,9 @@ import { classifyCommand } from './commands/classify.js';
|
|
|
24
24
|
import { claimCommand } from './commands/claim.js';
|
|
25
25
|
import { releaseCommand } from './commands/release.js';
|
|
26
26
|
import { statusCommand } from './commands/status.js';
|
|
27
|
+
import { tournamentCommand } from './commands/tournament.js';
|
|
28
|
+
import { autoCardCommand } from './commands/auto-card.js';
|
|
29
|
+
import { nextCommand } from './commands/next.js';
|
|
27
30
|
const subcommand = process.argv[2];
|
|
28
31
|
switch (subcommand) {
|
|
29
32
|
case 'init':
|
|
@@ -72,12 +75,21 @@ switch (subcommand) {
|
|
|
72
75
|
process.exit(1);
|
|
73
76
|
});
|
|
74
77
|
break;
|
|
78
|
+
case 'tournament':
|
|
79
|
+
tournamentCommand(process.argv.slice(3));
|
|
80
|
+
break;
|
|
81
|
+
case 'auto-card':
|
|
82
|
+
autoCardCommand(process.argv.slice(3));
|
|
83
|
+
break;
|
|
84
|
+
case 'next':
|
|
85
|
+
nextCommand();
|
|
86
|
+
break;
|
|
75
87
|
default:
|
|
76
88
|
console.log(`
|
|
77
89
|
SLOPE CLI — Sprint Lifecycle & Operational Performance Engine
|
|
78
90
|
|
|
79
91
|
Usage:
|
|
80
|
-
slope init [--claude-code]
|
|
92
|
+
slope init [--claude-code|--cursor|--generic] Initialize .slope/ directory
|
|
81
93
|
slope card Show handicap card
|
|
82
94
|
slope validate [path] Validate scorecard(s)
|
|
83
95
|
slope review [path] [--plain] Format sprint review markdown
|
|
@@ -88,9 +100,14 @@ Usage:
|
|
|
88
100
|
slope release --id=<id> Release a claim by ID
|
|
89
101
|
slope release --target=<t> [--player=<p>] Release a claim by target
|
|
90
102
|
slope status [--sprint=N] Show sprint course status + conflicts
|
|
103
|
+
slope tournament --id=<id> --sprints=N..M Build tournament review from sprints
|
|
104
|
+
slope auto-card --sprint=<N> [options] Generate scorecard from git commits
|
|
105
|
+
slope next Show next sprint number (auto-detect)
|
|
91
106
|
|
|
92
107
|
Examples:
|
|
93
108
|
slope init Create .slope/ with config + example scorecard
|
|
109
|
+
slope init --cursor Also install Cursor IDE rules
|
|
110
|
+
slope init --cursor Also add SLOPE MCP server to .cursor/mcp.json
|
|
94
111
|
slope init --claude-code Also install Claude Code rules + hooks
|
|
95
112
|
slope card Show handicap across all scorecards
|
|
96
113
|
slope validate docs/retros/sprint-1.json Validate a specific scorecard
|
package/dist/loader.js
CHANGED
|
@@ -39,6 +39,25 @@ export function loadScorecards(config, cwd = process.cwd()) {
|
|
|
39
39
|
}
|
|
40
40
|
return scorecards;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Detect the latest sprint number from existing scorecards.
|
|
44
|
+
* Returns 0 if no scorecards are found.
|
|
45
|
+
*/
|
|
46
|
+
export function detectLatestSprint(config, cwd = process.cwd()) {
|
|
47
|
+
const cards = loadScorecards(config, cwd);
|
|
48
|
+
if (cards.length === 0)
|
|
49
|
+
return 0;
|
|
50
|
+
return Math.max(...cards.map((c) => c.sprint_number));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the current sprint number: explicit config > auto-detect + 1.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveCurrentSprint(config, cwd = process.cwd()) {
|
|
56
|
+
if (config.currentSprint)
|
|
57
|
+
return config.currentSprint;
|
|
58
|
+
const latest = detectLatestSprint(config, cwd);
|
|
59
|
+
return latest + 1;
|
|
60
|
+
}
|
|
42
61
|
function escapeRegex(s) {
|
|
43
62
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
44
63
|
}
|