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/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.replace(/\b(no|without|not a|paid)\s+(take[- ]home|takehome)s?\b/gi, '');
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
- /\b([3-9]|[1-9]\d)\+?\s+years?\b/i.test(text),
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
- export const lintJobPost = (raw) => {
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 = rules.filter((rule) => rule.test(text)).map(({ id, level, message }) => ({ id, level, message }));
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 roleMatch = String(text || '').match(
109
- /\b(?:senior|staff|principal|lead|junior|entry[- ]level)?[ \t]*(?:full[- ]stack|frontend|backend|software|platform)?[ \t]*(?:engineer|developer|designer)\b/i,
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 atMatch = String(text || '').match(
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: atMatch ? atMatch[1].trim() : 'Hiring',
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');