jobless 1.4.0 → 1.6.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.
- package/README.md +115 -49
- package/package.json +4 -3
- package/src/cli.js +107 -202
- package/src/clip.js +18 -0
- package/src/data.js +106 -105
- package/src/diff.js +103 -0
- package/src/fit.js +41 -0
- package/src/hiring.js +70 -0
- package/src/identity.js +5 -1
- package/src/interactive.js +73 -0
- package/src/lint-cli.js +128 -0
- package/src/lint-screens.js +114 -0
- package/src/lint.js +443 -18
- package/src/profile-screens.js +248 -0
- package/src/reject.js +89 -18
- package/src/screens.js +57 -176
- package/src/style.js +16 -8
- package/src/work-data.js +152 -0
package/src/lint.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fitAgainst } from './fit.js';
|
|
1
2
|
import { detectIdentity } from './identity.js';
|
|
2
3
|
|
|
3
4
|
const hasMoney = (text) =>
|
|
@@ -8,93 +9,454 @@ const roundCount = (text) => {
|
|
|
8
9
|
return matches.reduce((max, match) => Math.max(max, Number(match[1])), 0);
|
|
9
10
|
};
|
|
10
11
|
|
|
12
|
+
const stripTakehomeNegation = (text) =>
|
|
13
|
+
text.replace(/\b(no|without|not a|paid)\s+(take[- ]home|takehome)s?\b/gi, '');
|
|
14
|
+
|
|
15
|
+
const clip = (value, max = 72) => {
|
|
16
|
+
const text = String(value || '')
|
|
17
|
+
.replace(/\s+/g, ' ')
|
|
18
|
+
.trim();
|
|
19
|
+
if (!text) return '';
|
|
20
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const locate = (text, needle) => {
|
|
24
|
+
if (!needle) return { line: null, snippet: null };
|
|
25
|
+
const lines = String(text).split(/\n/);
|
|
26
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
27
|
+
const match = lines[i].match(needle);
|
|
28
|
+
if (match) {
|
|
29
|
+
const snippet = clip(match[0].length >= 8 ? match[0] : lines[i]);
|
|
30
|
+
return { line: i + 1, snippet };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const match = String(text).match(needle);
|
|
34
|
+
if (!match) return { line: null, snippet: null };
|
|
35
|
+
const line = String(text).slice(0, match.index).split(/\n/).length;
|
|
36
|
+
return { line, snippet: clip(match[0]) };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const isDirective = (line) => /jobless-disable(?:-next-line|-line|-file)?\b/i.test(line);
|
|
40
|
+
|
|
41
|
+
export const locateAll = (text, needle) => {
|
|
42
|
+
if (!needle) return [];
|
|
43
|
+
const hits = [];
|
|
44
|
+
const lines = String(text).split('\n');
|
|
45
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
46
|
+
if (isDirective(lines[i])) continue;
|
|
47
|
+
const match = lines[i].match(needle);
|
|
48
|
+
if (match) {
|
|
49
|
+
hits.push({
|
|
50
|
+
line: i + 1,
|
|
51
|
+
snippet: clip(match[0].length >= 8 ? match[0] : lines[i]),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return hits;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const parseDisables = (text) => {
|
|
59
|
+
const lines = String(text).split('\n');
|
|
60
|
+
const lineMap = new Map();
|
|
61
|
+
const file = new Set();
|
|
62
|
+
const idsFrom = (raw) =>
|
|
63
|
+
raw
|
|
64
|
+
? raw
|
|
65
|
+
.split(/\s*,\s*/)
|
|
66
|
+
.map((item) => item.trim())
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
: ['*'];
|
|
69
|
+
const add = (line, ids) => {
|
|
70
|
+
const set = lineMap.get(line) || new Set();
|
|
71
|
+
for (const id of ids) set.add(id);
|
|
72
|
+
lineMap.set(line, set);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
76
|
+
const line = lines[i];
|
|
77
|
+
const next = line.match(/jobless-disable-next-line(?:\s+([a-z0-9-]+(?:\s*,\s*[a-z0-9-]+)*))?/i);
|
|
78
|
+
if (next) {
|
|
79
|
+
add(i + 2, idsFrom(next[1]));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const one = line.match(/jobless-disable-line(?:\s+([a-z0-9-]+(?:\s*,\s*[a-z0-9-]+)*))?/i);
|
|
83
|
+
if (one) {
|
|
84
|
+
add(i + 1, idsFrom(one[1]));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const whole = line.match(/jobless-disable-file(?:\s+([a-z0-9-]+(?:\s*,\s*[a-z0-9-]+)*))?/i);
|
|
88
|
+
if (whole) {
|
|
89
|
+
for (const id of idsFrom(whole[1])) file.add(id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { lineMap, file };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const ruleDisabledOn = (disables, line, ruleId) => {
|
|
97
|
+
if (disables.file.has('*') || disables.file.has(ruleId)) return true;
|
|
98
|
+
if (line == null) return false;
|
|
99
|
+
const set = disables.lineMap.get(line);
|
|
100
|
+
return Boolean(set && (set.has('*') || set.has(ruleId)));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const PLACEHOLDER_PAY = '\n\nSalary range: $120,000–$160,000 (placeholder — put the real range).';
|
|
104
|
+
|
|
11
105
|
export const rules = [
|
|
12
106
|
{
|
|
13
107
|
id: 'no-fake-salary',
|
|
14
108
|
level: 'error',
|
|
15
109
|
message: 'salary described as competitive / attractive / market-rate, with no number',
|
|
110
|
+
why: 'If you can name the role, you can name the range.',
|
|
111
|
+
needle: /\b(competitive|attractive|market[- ]rate)\b.{0,24}\b(salary|compensat\w*|pay|package)\b|\b(salary|compensat\w*|pay|package)\b.{0,24}\b(competitive|attractive|market[- ]rate)\b/i,
|
|
16
112
|
test: (text) =>
|
|
17
113
|
/\b(competitive|attractive|market[- ]rate)\b/i.test(text) &&
|
|
18
114
|
/\b(salary|compensat|pay|package)\b/i.test(text) &&
|
|
19
115
|
!hasMoney(text),
|
|
116
|
+
fix: (text) => (hasMoney(text) ? text : `${text.trim()}${PLACEHOLDER_PAY}`),
|
|
20
117
|
},
|
|
21
118
|
{
|
|
22
119
|
id: 'no-salary-range',
|
|
23
120
|
level: 'error',
|
|
24
121
|
message: 'no salary range anywhere in the posting',
|
|
122
|
+
why: 'Compensation is a number. "Competitive" is a vibe.',
|
|
25
123
|
test: (text) => !hasMoney(text) && !/\bsalary range\b|\bcompensation range\b/i.test(text),
|
|
124
|
+
fix: (text) => (hasMoney(text) ? text : `${text.trim()}${PLACEHOLDER_PAY}`),
|
|
26
125
|
},
|
|
27
126
|
{
|
|
28
127
|
id: 'max-rounds',
|
|
29
128
|
level: 'error',
|
|
30
129
|
message: 'too many interview rounds',
|
|
130
|
+
why: 'Five-plus rounds is a process, not a signal.',
|
|
131
|
+
needle: /(\d+)\s*\+?\s*(interview\s+)?(rounds?|stages?|steps?)\b|(six|seven|eight)\s+(interview\s+)?rounds?\b/i,
|
|
31
132
|
test: (text) => roundCount(text) >= 5 || /\b(six|seven|eight)\s+(interview\s+)?rounds\b/i.test(text),
|
|
133
|
+
fix: (text) =>
|
|
134
|
+
text
|
|
135
|
+
.replace(/\b(six|seven|eight|\d+)\s+(interview\s+)?rounds?\b/gi, '3 interview rounds')
|
|
136
|
+
.replace(/\b(six|seven|eight|\d+)\s+(interview\s+)?(stages?|steps?)\b/gi, '3 interview rounds'),
|
|
32
137
|
},
|
|
33
138
|
{
|
|
34
139
|
id: 'unpaid-takehome',
|
|
35
140
|
level: 'error',
|
|
36
141
|
message: 'take-home that looks like unpaid work',
|
|
142
|
+
why: 'If it ships value, pay for it.',
|
|
143
|
+
needle: /\b(take[- ]home|takehome|homework|unpaid (assignment|project)|weekend (project|assignment))\b/i,
|
|
37
144
|
test: (text) => {
|
|
38
|
-
const stripped = text
|
|
145
|
+
const stripped = stripTakehomeNegation(text);
|
|
39
146
|
return /\b(take[- ]home|takehome|homework|unpaid (assignment|project)|weekend (project|assignment))\b/i.test(
|
|
40
147
|
stripped,
|
|
41
148
|
);
|
|
42
149
|
},
|
|
150
|
+
fix: (text) =>
|
|
151
|
+
text.replace(
|
|
152
|
+
/\b(unpaid\s+)?(take[- ]home|takehome|homework)s?\b/gi,
|
|
153
|
+
'paid time-boxed work sample',
|
|
154
|
+
),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'unbounded-takehome',
|
|
158
|
+
level: 'error',
|
|
159
|
+
message: 'take-home with no time box',
|
|
160
|
+
why: 'Unbounded homework is just the roadmap with a Sunday deadline.',
|
|
161
|
+
needle: /\b(take[- ]home|takehome)\b/i,
|
|
162
|
+
test: (text) => {
|
|
163
|
+
const stripped = text.replace(/\b(no|without|not a)\s+(take[- ]home|takehome)s?\b/gi, '');
|
|
164
|
+
const hasTakehome = /\b(take[- ]home|takehome)\b/i.test(stripped);
|
|
165
|
+
const hasBudget = /\b(\d+\s*(hour|hr)s?|time[- ]boxed|timeboxed)\b/i.test(text);
|
|
166
|
+
return hasTakehome && !hasBudget;
|
|
167
|
+
},
|
|
168
|
+
fix: (text) => `${text.trim()}\n\nAny work sample is paid and time-boxed to 3 hours.`,
|
|
43
169
|
},
|
|
44
170
|
{
|
|
45
171
|
id: 'entry-needs-years',
|
|
46
172
|
level: 'error',
|
|
47
173
|
message: 'entry-level / junior role asking for years of experience',
|
|
174
|
+
why: 'Junior is not a synonym for cheap senior.',
|
|
175
|
+
needle: /\b(entry[- ]level|junior)\b/i,
|
|
48
176
|
test: (text) =>
|
|
49
|
-
/\b(entry[- ]level|junior)\b/i.test(text) &&
|
|
50
|
-
|
|
177
|
+
/\b(entry[- ]level|junior)\b/i.test(text) && /\b([3-9]|[1-9]\d)\+?\s+years?\b/i.test(text),
|
|
178
|
+
fix: (text) => text.replace(/\b(entry[- ]level|junior)\b/gi, 'this'),
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'nights-and-weekends',
|
|
182
|
+
level: 'error',
|
|
183
|
+
message: 'nights, weekends, or crunch as a requirement',
|
|
184
|
+
why: 'Crunch in the posting is crunch in the job.',
|
|
185
|
+
needle: /\b(nights and weekends|crunch|weekends required|on[- ]call 24)\b/i,
|
|
186
|
+
test: (text) => /\b(nights and weekends|crunch|weekends required|on[- ]call 24)\b/i.test(text),
|
|
187
|
+
fix: (text) =>
|
|
188
|
+
text.replace(
|
|
189
|
+
/\b(nights and weekends|crunch|weekends required|on[- ]call 24\/?7?)\b/gi,
|
|
190
|
+
'weekday hours; on-call is compensated',
|
|
191
|
+
),
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: 'remote-but-office',
|
|
195
|
+
level: 'error',
|
|
196
|
+
message: 'lists remote, then requires the office',
|
|
197
|
+
why: 'Remote means remote. The office is a different job.',
|
|
198
|
+
needle: /\b(in[- ]office|on[- ]site|5 days (a|in) (the )?office)\b/i,
|
|
199
|
+
test: (text) =>
|
|
200
|
+
/\bremote\b/i.test(text) &&
|
|
201
|
+
/\b(in[- ]office 5|on[- ]site 5|5 days (a|in) (the )?office|must be in (the )?office)\b/i.test(text),
|
|
202
|
+
fix: (text) =>
|
|
203
|
+
text.replace(
|
|
204
|
+
/\b,?\s*must be in (the )?office(\s+\d+\s+days)?\b/gi,
|
|
205
|
+
'',
|
|
206
|
+
).replace(/\b(in[- ]office|on[- ]site)\s+5 days\b/gi, 'remote'),
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: 'equity-only',
|
|
210
|
+
level: 'error',
|
|
211
|
+
message: 'equity / competitive equity with no number',
|
|
212
|
+
why: 'Equity without a percentage is a lottery ticket with no odds.',
|
|
213
|
+
needle: /\b(competitive equity|equity-only|paid in equity)\b/i,
|
|
214
|
+
test: (text) =>
|
|
215
|
+
/\b(competitive equity|equity-only|paid in equity)\b/i.test(text) && !/%|\bpercent\b/i.test(text),
|
|
216
|
+
fix: (text) => `${text.trim()}\n\nEquity: stated percentage (placeholder — put the real grant).`,
|
|
51
217
|
},
|
|
52
218
|
{
|
|
53
219
|
id: 'means-understaffed',
|
|
54
220
|
level: 'warn',
|
|
55
221
|
message: '"fast-paced" / "wear many hats" / "do more with less"',
|
|
222
|
+
why: 'Usually means understaffed and proud of it.',
|
|
223
|
+
needle: /\bfast[- ]paced\b|\bwear many hats\b|\bdo more with less\b|\bhustle\b|\blean team\b/i,
|
|
56
224
|
test: (text) =>
|
|
57
225
|
/\bfast[- ]paced\b|\bwear many hats\b|\bdo more with less\b|\bhustle\b|\blean team\b/i.test(text),
|
|
226
|
+
fix: (text) =>
|
|
227
|
+
text
|
|
228
|
+
.replace(/\bfast[- ]paced\b/gi, 'staffed')
|
|
229
|
+
.replace(/\bwear many hats\b/gi, 'have a defined role')
|
|
230
|
+
.replace(/\bdo more with less\b/gi, 'have enough people')
|
|
231
|
+
.replace(/\bhustle\b/gi, 'ship')
|
|
232
|
+
.replace(/\blean team\b/gi, 'team'),
|
|
58
233
|
},
|
|
59
234
|
{
|
|
60
235
|
id: 'rockstar-ninja',
|
|
61
236
|
level: 'warn',
|
|
62
237
|
message: 'rockstar / ninja / 10x / guru',
|
|
238
|
+
why: 'Hire an engineer. The costume closet is full.',
|
|
239
|
+
needle: /\b(rockstar|ninja|10x|guru|superstar|wizard|code monkey)s?\b/i,
|
|
63
240
|
test: (text) => /\b(rockstar|ninja|10x|guru|superstar|wizard|code monkey)\b/i.test(text),
|
|
241
|
+
fix: (text) =>
|
|
242
|
+
text.replace(
|
|
243
|
+
/\b(?:(rockstar|ninja|10x|guru|superstar|wizard|code monkey)s?\s*)+/gi,
|
|
244
|
+
'engineer ',
|
|
245
|
+
),
|
|
64
246
|
},
|
|
65
247
|
{
|
|
66
248
|
id: 'we-are-family',
|
|
67
249
|
level: 'warn',
|
|
68
250
|
message: 'we are a family',
|
|
251
|
+
why: 'Families do not PIP you. Companies do.',
|
|
252
|
+
needle: /\b(we['’]re a family|we are a family|our family|work family)\b/i,
|
|
69
253
|
test: (text) => /\b(we['’]re a family|we are a family|our family|work family)\b/i.test(text),
|
|
254
|
+
fix: (text) =>
|
|
255
|
+
text
|
|
256
|
+
.replace(/\b(?:who is )?passionate about (?:our family|we['’]re a family|we are a family)\b/gi, '')
|
|
257
|
+
.replace(/\b(we['’]re a family|we are a family|our family|work family)\b/gi, "we're a team")
|
|
258
|
+
.replace(/[ ]{2,}/g, ' '),
|
|
70
259
|
},
|
|
71
260
|
{
|
|
72
261
|
id: 'unlimited-pto',
|
|
73
262
|
level: 'warn',
|
|
74
263
|
message: 'unlimited PTO (often means unused PTO)',
|
|
264
|
+
why: 'Unlimited usually means unused. Write the days.',
|
|
265
|
+
needle: /\bunlimited (pto|vacation|time off)\b/i,
|
|
75
266
|
test: (text) => /\bunlimited (pto|vacation|time off)\b/i.test(text),
|
|
76
|
-
|
|
77
|
-
{
|
|
78
|
-
id: 'nights-and-weekends',
|
|
79
|
-
level: 'error',
|
|
80
|
-
message: 'nights, weekends, or crunch as a requirement',
|
|
81
|
-
test: (text) => /\b(nights and weekends|crunch|weekends required|on[- ]call 24)\b/i.test(text),
|
|
267
|
+
fix: (text) => text.replace(/\bunlimited (pto|vacation|time off)\b/gi, 'accrued PTO with a stated balance'),
|
|
82
268
|
},
|
|
83
269
|
{
|
|
84
270
|
id: 'passion-tax',
|
|
85
271
|
level: 'warn',
|
|
86
272
|
message: 'looking for passion instead of paying for labor',
|
|
273
|
+
why: 'Passion is not a compensation strategy.',
|
|
274
|
+
needle: /\b(passionate about|must love|looking for passion)\b/i,
|
|
87
275
|
test: (text) => /\b(passionate about|must love|looking for passion)\b/i.test(text),
|
|
276
|
+
fix: (text) =>
|
|
277
|
+
text
|
|
278
|
+
.replace(/\bpassionate about\b/gi, 'experienced in')
|
|
279
|
+
.replace(/\bmust love\b/gi, 'will work on')
|
|
280
|
+
.replace(/\blooking for passion\b/gi, 'hiring for skill'),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: 'founding-engineer-theater',
|
|
284
|
+
level: 'warn',
|
|
285
|
+
message: '"founding engineer" at a company that already has a team',
|
|
286
|
+
why: 'Founding is a round, not a vibe on a 200-person roster.',
|
|
287
|
+
needle: /\bfounding engineer\b/i,
|
|
288
|
+
test: (text) =>
|
|
289
|
+
/\bfounding engineer\b/i.test(text) &&
|
|
290
|
+
/\b(\d{2,}\+?\s+(people|employees|engineers)|hundreds? of)\b/i.test(text),
|
|
291
|
+
fix: (text) => text.replace(/\bfounding engineer\b/gi, 'engineer'),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: 'visa-silence',
|
|
295
|
+
level: 'warn',
|
|
296
|
+
message: 'global / anywhere posting with no visa or sponsorship mention',
|
|
297
|
+
why: 'Worldwide without a visa sentence is a filter, not a welcome.',
|
|
298
|
+
needle: /\b(worldwide|global team|work from anywhere|anywhere in the world)\b/i,
|
|
299
|
+
test: (text) =>
|
|
300
|
+
/\b(worldwide|global team|work from anywhere|anywhere in the world)\b/i.test(text) &&
|
|
301
|
+
!/\b(visa|sponsor|sponsorship|work authorization)\b/i.test(text),
|
|
302
|
+
fix: (text) => `${text.trim()}\n\nVisa / sponsorship: say yes or no, in writing.`,
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: 'ai-first-vapor',
|
|
306
|
+
level: 'warn',
|
|
307
|
+
message: '"AI-first" / "AI-powered" with no product sentence',
|
|
308
|
+
why: 'If there is no product, there is no AI-first. There is a deck.',
|
|
309
|
+
needle: /\bai[- ]first\b|\bai[- ]powered\b/i,
|
|
310
|
+
test: (text) =>
|
|
311
|
+
/\bai[- ]first\b|\bai[- ]powered\b/i.test(text) &&
|
|
312
|
+
!/\b(product|users|customers|shipped|revenue)\b/i.test(text),
|
|
313
|
+
fix: (text) =>
|
|
314
|
+
text.replace(/\bai[- ]first\b/gi, 'product').replace(/\bai[- ]powered\b/gi, 'product'),
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
id: 'culture-fit',
|
|
318
|
+
level: 'warn',
|
|
319
|
+
message: 'culture fit / young and hungry / work hard play hard',
|
|
320
|
+
why: 'Code for hours that are not written down.',
|
|
321
|
+
needle: /\bculture fit\b|\byoung and hungry\b|\bwork hard[, ]+play hard\b/i,
|
|
322
|
+
test: (text) =>
|
|
323
|
+
/\bculture fit\b|\byoung and hungry\b|\bwork hard[, ]+play hard\b/i.test(text),
|
|
324
|
+
fix: (text) =>
|
|
325
|
+
text
|
|
326
|
+
.replace(/\bculture fit\b/gi, 'can do the job')
|
|
327
|
+
.replace(/\byoung and hungry\b/gi, 'experienced')
|
|
328
|
+
.replace(/\bwork hard[, ]+play hard\b/gi, 'work hours that are written down'),
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: 'exact-stack-religion',
|
|
332
|
+
level: 'warn',
|
|
333
|
+
message: 'must have used this exact stack in production',
|
|
334
|
+
why: 'Strong engineers learn stacks. Exact-match is a recruiter checkbox.',
|
|
335
|
+
needle: /\b(exact stack|our exact|must have (used|experience with) our)\b/i,
|
|
336
|
+
test: (text) => /\b(exact stack|our exact|must have (used|experience with) our)\b/i.test(text),
|
|
337
|
+
fix: (text) =>
|
|
338
|
+
text.replace(
|
|
339
|
+
/\b(must have (used|experience with) our exact stack|our exact stack|exact stack)\b/gi,
|
|
340
|
+
'strong engineers who can learn the stack',
|
|
341
|
+
),
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: 'salary-doe',
|
|
345
|
+
level: 'error',
|
|
346
|
+
message: 'salary is DOE / commensurate with experience, with no number',
|
|
347
|
+
why: 'DOE is competitive with extra steps.',
|
|
348
|
+
needle: /\b(DOE|D\.O\.E\.|commensurate with experience)\b/i,
|
|
349
|
+
test: (text) => /\b(DOE|D\.O\.E\.|commensurate with experience)\b/i.test(text) && !hasMoney(text),
|
|
350
|
+
fix: (text) => (hasMoney(text) ? text : `${text.trim()}${PLACEHOLDER_PAY}`),
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: 'ancient-years',
|
|
354
|
+
level: 'error',
|
|
355
|
+
message: 'too many years required for a young tool',
|
|
356
|
+
why: 'Next.js is not old enough to demand a decade.',
|
|
357
|
+
needle: /\b(8|9|[1-9]\d)\+?\s+years?\b.{0,40}\b(next\.js|graphql|kubernetes|typescript|tailwind)\b/i,
|
|
358
|
+
test: (text) =>
|
|
359
|
+
/\b(8|9|[1-9]\d)\+?\s+years?\b.{0,40}\b(next\.js|graphql|kubernetes|typescript|tailwind)\b/i.test(text),
|
|
360
|
+
fix: (text) =>
|
|
361
|
+
text.replace(
|
|
362
|
+
/\b(8|9|[1-9]\d)\+?\s+years? (of |with )?(next\.js|graphql|kubernetes|typescript|tailwind)\b/gi,
|
|
363
|
+
'production experience with $3',
|
|
364
|
+
),
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
id: 'perk-theater',
|
|
368
|
+
level: 'warn',
|
|
369
|
+
message: 'snacks / ping-pong / kombucha listed as a benefit',
|
|
370
|
+
why: 'A fridge is not a benefits package.',
|
|
371
|
+
needle: /\b(ping[- ]pong|foosball|kombucha|beer fridge|free snacks|snack bar|nap pod)\b/i,
|
|
372
|
+
test: (text) =>
|
|
373
|
+
/\b(ping[- ]pong|foosball|kombucha|beer fridge|free snacks|snack bar|nap pod)\b/i.test(text),
|
|
374
|
+
fix: (text) =>
|
|
375
|
+
text.replace(
|
|
376
|
+
/\b(ping[- ]pong|foosball|kombucha|beer fridge|free snacks|snack bar|nap pod)( and )?/gi,
|
|
377
|
+
'',
|
|
378
|
+
),
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
id: 'native-english',
|
|
382
|
+
level: 'error',
|
|
383
|
+
message: 'native English as a filter',
|
|
384
|
+
why: 'Fluent is a skill. Native is a census category.',
|
|
385
|
+
needle: /\b(native english|english native speaker|native speaker of english)\b/i,
|
|
386
|
+
test: (text) => /\b(native english|english native speaker|native speaker of english)\b/i.test(text),
|
|
387
|
+
fix: (text) =>
|
|
388
|
+
text.replace(
|
|
389
|
+
/\b(native english|english native speaker|native speaker of english)\b/gi,
|
|
390
|
+
'clear written and spoken English',
|
|
391
|
+
),
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
id: 'unpaid-trial',
|
|
395
|
+
level: 'error',
|
|
396
|
+
message: 'contract-to-hire / trial that looks unpaid',
|
|
397
|
+
why: 'A trial is a job. Jobs are paid.',
|
|
398
|
+
needle: /\b(contract[- ]to[- ]hire|unpaid trial|trial period)\b/i,
|
|
399
|
+
test: (text) =>
|
|
400
|
+
/\bunpaid trial\b/i.test(text) ||
|
|
401
|
+
(/\bcontract[- ]to[- ]hire\b/i.test(text) && /\bunpaid\b/i.test(text)),
|
|
402
|
+
fix: (text) =>
|
|
403
|
+
text.replace(/\bunpaid trial\b/gi, 'paid trial').replace(/\bcontract[- ]to[- ]hire\b/gi, 'full-time role'),
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
id: 'fake-startup',
|
|
407
|
+
level: 'warn',
|
|
408
|
+
message: '"startup energy" at a company that already has a headcount',
|
|
409
|
+
why: 'Startup is a stage. Headcount is a fact.',
|
|
410
|
+
needle: /\bstartup (energy|culture|vibe|feel)\b/i,
|
|
411
|
+
test: (text) =>
|
|
412
|
+
/\bstartup (energy|culture|vibe|feel)\b/i.test(text) &&
|
|
413
|
+
/\b(\d{3,}|\d+\s*k|\bthousands?)\s+(people|employees|engineers)\b/i.test(text),
|
|
414
|
+
fix: (text) => text.replace(/\bstartup (energy|culture|vibe|feel)\b/gi, 'company'),
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
id: 'degree-when-years',
|
|
418
|
+
level: 'warn',
|
|
419
|
+
message: 'degree required on a role that already asks for years',
|
|
420
|
+
why: 'Five years in production already sat the exam.',
|
|
421
|
+
needle: /\b(bachelor|bachelor'?s|bs\/ms|degree required|must have a degree)\b/i,
|
|
422
|
+
test: (text) =>
|
|
423
|
+
/\b(bachelor|bachelor'?s|bs\/ms|degree required|must have a degree)\b/i.test(text) &&
|
|
424
|
+
/\b([5-9]|[1-9]\d)\+?\s+years?\b/i.test(text),
|
|
425
|
+
fix: (text) =>
|
|
426
|
+
text.replace(
|
|
427
|
+
/\b(bachelor'?s degree|bachelor degree|bs\/ms|degree required|must have a degree)\b/gi,
|
|
428
|
+
'relevant experience',
|
|
429
|
+
),
|
|
88
430
|
},
|
|
89
431
|
];
|
|
90
432
|
|
|
91
|
-
|
|
433
|
+
const disabledSet = (off = []) =>
|
|
434
|
+
new Set(
|
|
435
|
+
(Array.isArray(off) ? off : String(off).split(','))
|
|
436
|
+
.map((id) => String(id).trim())
|
|
437
|
+
.filter(Boolean),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
export const lintJobPost = (raw, { off = [] } = {}) => {
|
|
92
441
|
const text = String(raw || '').trim();
|
|
442
|
+
const disabled = disabledSet(off);
|
|
443
|
+
const disables = parseDisables(text);
|
|
93
444
|
if (!text) {
|
|
94
|
-
return { text, findings: [{ id: 'empty', level: 'error', message: 'no job description provided' }] };
|
|
445
|
+
return { text, findings: [{ id: 'empty', level: 'error', message: 'no job description provided', line: null, snippet: null }] };
|
|
95
446
|
}
|
|
96
447
|
|
|
97
|
-
const findings =
|
|
448
|
+
const findings = [];
|
|
449
|
+
for (const rule of rules) {
|
|
450
|
+
if (disabled.has(rule.id) || disables.file.has(rule.id) || disables.file.has('*')) continue;
|
|
451
|
+
if (!rule.test(text)) continue;
|
|
452
|
+
if (rule.needle) {
|
|
453
|
+
const hits = locateAll(text, rule.needle).filter((hit) => !ruleDisabledOn(disables, hit.line, rule.id));
|
|
454
|
+
if (!hits.length) continue;
|
|
455
|
+
findings.push({ id: rule.id, level: rule.level, message: rule.message, why: rule.why, ...hits[0] });
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
findings.push({ id: rule.id, level: rule.level, message: rule.message, why: rule.why, line: null, snippet: null });
|
|
459
|
+
}
|
|
98
460
|
return { text, findings };
|
|
99
461
|
};
|
|
100
462
|
|
|
@@ -104,35 +466,60 @@ export const summarizeLint = (findings) => {
|
|
|
104
466
|
return { errors, warnings };
|
|
105
467
|
};
|
|
106
468
|
|
|
469
|
+
export const scoreLint = (errors, warnings) => Math.max(0, Math.min(100, 100 - errors * 18 - warnings * 8));
|
|
470
|
+
|
|
471
|
+
const COMPANY_STOP =
|
|
472
|
+
/^(Competitive|Attractive|Salary|Compensation|Remote|We|Our|The|This|Looking|Join|About|Fast|Unlimited|Senior|Staff|A|An|Full)$/i;
|
|
473
|
+
|
|
107
474
|
export const postingMeta = (text) => {
|
|
108
|
-
const
|
|
109
|
-
|
|
475
|
+
const raw = String(text || '');
|
|
476
|
+
const flat = raw.replace(/\s+/g, ' ');
|
|
477
|
+
const roleMatch = flat.match(
|
|
478
|
+
/\b(?:senior|staff|principal|lead|junior|entry[- ]level)?\s*(?:full[- ]stack|frontend|backend|software|platform)?\s*(?:engineer|developer|designer)\b/i,
|
|
479
|
+
);
|
|
480
|
+
const fromJoinUs = raw.match(
|
|
481
|
+
/\bjoin us at[ \t]+([A-Z][A-Za-z0-9&.'\-]{1,32}(?:[ \t]+[A-Z][A-Za-z0-9&.'\-]{1,20}){0,2})/i,
|
|
110
482
|
);
|
|
111
|
-
const
|
|
483
|
+
const fromAt = raw.match(
|
|
112
484
|
/\b(?:at|@|join)[ \t]+([A-Z][A-Za-z0-9&.'\-]{1,32}(?:[ \t]+[A-Z][A-Za-z0-9&.'\-]{1,20}){0,2})/,
|
|
113
485
|
);
|
|
486
|
+
const fromHiring = raw.match(
|
|
487
|
+
/\b([A-Z][A-Za-z0-9&.'\-]{1,32}(?:[ \t]+[A-Z][A-Za-z0-9&.'\-]{1,20}){0,2})[ \t]+is hiring\b/,
|
|
488
|
+
);
|
|
489
|
+
const captured = fromJoinUs?.[1] || fromAt?.[1] || fromHiring?.[1] || '';
|
|
490
|
+
const company =
|
|
491
|
+
captured
|
|
492
|
+
.split(/[ \t]+/)
|
|
493
|
+
.filter((word) => !COMPANY_STOP.test(word))
|
|
494
|
+
.join(' ')
|
|
495
|
+
.trim() || 'Hiring';
|
|
496
|
+
|
|
114
497
|
return {
|
|
115
498
|
role: roleMatch ? roleMatch[0].replace(/\s+/g, ' ').trim() : 'this role',
|
|
116
|
-
company
|
|
499
|
+
company,
|
|
117
500
|
};
|
|
118
501
|
};
|
|
119
502
|
|
|
120
|
-
export const lintResult = (raw, source = 'stdin') => {
|
|
121
|
-
const { text, findings } = lintJobPost(raw);
|
|
503
|
+
export const lintResult = (raw, source = 'stdin', { off = [] } = {}) => {
|
|
504
|
+
const { text, findings } = lintJobPost(raw, { off });
|
|
122
505
|
const { errors, warnings } = summarizeLint(findings);
|
|
123
506
|
const identity = detectIdentity();
|
|
124
507
|
const meta = postingMeta(text);
|
|
125
508
|
const errorReasons = findings.filter((item) => item.level === 'error').map((item) => item.message);
|
|
126
509
|
const warnReasons = findings.filter((item) => item.level === 'warn').map((item) => item.message);
|
|
127
510
|
const reasons = (errorReasons.length ? errorReasons : warnReasons).slice(0, 2);
|
|
511
|
+
const score = scoreLint(errors, warnings);
|
|
512
|
+
const fit = fitAgainst(text);
|
|
128
513
|
|
|
129
514
|
return {
|
|
130
515
|
source,
|
|
131
516
|
ok: errors === 0,
|
|
517
|
+
score,
|
|
132
518
|
errors,
|
|
133
519
|
warnings,
|
|
134
520
|
findings,
|
|
135
521
|
meta,
|
|
522
|
+
fit,
|
|
136
523
|
letter:
|
|
137
524
|
findings.length === 0
|
|
138
525
|
? null
|
|
@@ -145,3 +532,41 @@ export const lintResult = (raw, source = 'stdin') => {
|
|
|
145
532
|
},
|
|
146
533
|
};
|
|
147
534
|
};
|
|
535
|
+
|
|
536
|
+
export const fixJobPost = (raw, { off = [] } = {}) => {
|
|
537
|
+
const disabled = disabledSet(off);
|
|
538
|
+
let text = String(raw || '');
|
|
539
|
+
const applied = [];
|
|
540
|
+
for (const rule of rules) {
|
|
541
|
+
if (disabled.has(rule.id) || !rule.fix) continue;
|
|
542
|
+
if (!rule.test(text)) continue;
|
|
543
|
+
const next = rule.fix(text);
|
|
544
|
+
if (next !== text) {
|
|
545
|
+
applied.push(rule.id);
|
|
546
|
+
text = next;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
text = polishFix(text);
|
|
550
|
+
return { text, applied };
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const polishFix = (text) =>
|
|
554
|
+
String(text)
|
|
555
|
+
.replace(/\ba engineer\b/gi, 'an engineer')
|
|
556
|
+
.replace(/\bwho is we're a team\b/gi, '')
|
|
557
|
+
.replace(/\bis we're a team\b/gi, 'is a team')
|
|
558
|
+
.replace(/[ ]{2,}/g, ' ')
|
|
559
|
+
.replace(/ +([.,;:])/g, '$1')
|
|
560
|
+
.replace(/ +\n/g, '\n');
|
|
561
|
+
|
|
562
|
+
export const githubAnnotations = (report) =>
|
|
563
|
+
report.findings
|
|
564
|
+
.map((finding) => {
|
|
565
|
+
const kind = finding.level === 'error' ? 'error' : 'warning';
|
|
566
|
+
const file =
|
|
567
|
+
!report.source || report.source === 'stdin' || report.source === 'paste' ? 'posting.txt' : report.source;
|
|
568
|
+
const line = finding.line ? `,line=${finding.line}` : '';
|
|
569
|
+
const title = finding.id ? `,title=${finding.id}` : '';
|
|
570
|
+
return `::${kind} file=${file}${line}${title}::${finding.message}`;
|
|
571
|
+
})
|
|
572
|
+
.join('\n');
|