jobless 1.5.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/data.js CHANGED
@@ -1,3 +1,5 @@
1
+ export { experience, cases, work, mentorship } from './work-data.js';
2
+
1
3
  export const profile = {
2
4
  name: 'Yonas Alem',
3
5
  title: 'Senior Software Engineer',
@@ -130,175 +132,27 @@ export const lookingFor = {
130
132
 
131
133
  export const rants = [
132
134
  {
133
- title: 'LinkedIn: 40% more thought leadership, 0% more jobs',
134
- body: "LinkedIn used to be a jobs board that accidentally had a feed. Now it is a feed that accidentally has jobs. Everyone is humbled. Everyone is announcing. Everyone is a thought leader on a Tuesday. The algorithm loves a carousel called '10 habits of senior engineers' from someone who has never been on-call. It does not love the person who actually kept production up. I did not open this app to like your new chapter. I opened it because I can build the thing you are posting about.",
135
- },
136
- {
137
- title: 'I applied for 30 minutes. The bot needed 400ms',
138
- body: 'The modern application is unpaid data entry with extra steps. Upload the PDF. Then type the PDF into twelve boxes the parser already filled wrong. Then salary in USD, GBP, and emotional damage. Then a culture quiz written by someone who has never been in the culture. Then "why this company?" as if Greenhouse is going to light a candle and read it. Submit. Rejected in 400ms. Sometimes the no arrives before the confirmation. Five years in production, $100K pulled out of infra, payment rails that moved billions — filtered out because I did not write "synergy" enough times. Cool product. Would not ship it.',
139
- },
140
- {
141
- title: 'They booked the call so they had something to cancel',
142
- body: 'Favorite genre of fiction: the recruiter calendar invite. "Loved your profile. Thursday 10am?" I clear the morning. I make tea. I put on a shirt with buttons. 9:58am: "we\'ve decided to move forward with other candidates." Which other candidates. The ones you have not scheduled yet? Or they just never show, and the Google Calendar event is the last proof a human existed. If ghosting is the process, skip the theater. Do not put a working engineer on your Greenhouse stage so the pipeline looks busy. I debug silent failures for a living. Yours is not that silent.',
143
- },
144
- {
145
- title: 'I do not know their coffee, their mug, or their stars',
146
- body: "The job posting said TypeScript, PostgreSQL, and 'excellent communication.' The actual exam is telepathy. What is on the recruiter's mind at 10:02am. Oat milk or 'just black, I ship.' What color is the mug. Company merch that says world-class talent, or the chipped navy one from a 2019 conference that still has a lipstick ghost. What is their star sign. A Virgo who will reject you for a missing Oxford comma. A Gemini who double-booked you with someone named also-Yonas. A Scorpio who already decided during the handshake. I can trace a silent cron death across 17 processes. I cannot see into their mug. Apparently that was the seniority gap. Next round I will bring a natal chart, a color swatch, and a cortado. Or we could talk about the actual system.",
147
- },
148
- ];
149
-
150
- export const experience = [
151
- {
152
- role: 'Senior Full Stack Engineer',
153
- company: 'TPK Synergy Limited',
154
- dates: 'Dec 2025 – Present',
155
- place: 'Remote',
156
- points: [
157
- 'Amazon seller analytics: marketplace analytics, FBA, finance, PPC, and inventory.',
158
- 'Diagnosed sync jobs that died after every deploy; shutdown handling across 17 cron processes. See `proof`.',
159
- 'Amazon SP-API and Ads auth end to end: OAuth, LWA, encrypted credentials.',
160
- 'UK and EU marketplaces with VAT-aware margins. Moved expensive aggregations into SQL.',
161
- ],
162
- stack: ['Node.js', 'PostgreSQL', 'BullMQ', 'Redis', 'React', 'Amazon SP-API'],
135
+ title: 'Distributed hiring',
136
+ body: 'The interview invite and rejection arrived together. Finally, a company doing parallel processing.',
163
137
  },
164
138
  {
165
- role: 'Senior Software Engineer',
166
- company: 'Mereb Technologies',
167
- dates: 'Apr 2022 – Nov 2025',
168
- place: 'Addis Ababa',
169
- points: [
170
- 'Owned the production gaming platform end to end in these domains: loyalty, payouts, fraud, reporting — not the whole company.',
171
- '~30% platform performance: indexing, read/write separation, caching, pagination, redesigned status-checking.',
172
- '~35% infra cost, about $100K a year: database, cache, and resource optimization. Engineering estimate.',
173
- 'Reports on ~1M-row datasets: 2 hours to under 30 minutes. Mentored 3 juniors on APIs, debugging, and design.',
174
- ],
175
- stack: ['PostgreSQL', 'gRPC', 'PHP', 'React', 'AWS', 'Datadog'],
139
+ title: 'Disaster recovery',
140
+ body: 'They asked how I handle rejection. Apparently: semantic versioning.',
176
141
  },
177
142
  {
178
- role: 'Full Stack Developer (Contract)',
179
- company: 'Affiliate.com',
180
- dates: 'Feb 2023 – Aug 2023',
181
- place: 'Remote · alongside Mereb',
182
- points: [
183
- 'Migrated a legacy PHP API to Node.js and tRPC without breaking the React UI.',
184
- 'Reimplemented legacy SQL in Prisma while both platforms ran in parallel.',
185
- 'Cut report generation ~40% by moving it onto Google Cloud Storage. ~85% coverage on migrated code.',
186
- ],
187
- stack: ['Node.js', 'tRPC', 'Prisma', 'React', 'Zod', 'GCS'],
143
+ title: 'Single source of truth',
144
+ body: 'Upload your resume. Now type it again. Your first task is keeping two databases in sync. Unpaid.',
188
145
  },
189
146
  {
190
- role: 'Backend Engineer (Contract)',
191
- company: 'Autobridge Systems',
192
- dates: 'Contract',
193
- place: 'Addis Ababa · government constituent platform',
194
- points: [
195
- 'Backend for complaints, document approvals, and internal communication, with access control and encryption.',
196
- 'Traced multi-minute email-record loads to missing indexes. Those operations improved more than 10x.',
197
- ],
198
- stack: ['PostgreSQL', 'Node.js', 'Encryption'],
147
+ title: 'Technical assessment',
148
+ body: 'The take-home was their Q3 roadmap. I submitted a quote.',
199
149
  },
200
150
  {
201
- role: 'Frontend Developer',
202
- company: 'Eaglelion Systems Technology',
203
- dates: 'Feb 2021 – Apr 2022',
204
- place: 'Addis Ababa',
205
- points: [
206
- 'Client payment workflows on products with 100K+ downloads that have handled 3B+ ETB. I integrated the rails, not the ledger.',
207
- 'Mastercard, Visa, Telebirr, bank APIs: states, callbacks, failures, retries, verification. SOAP→REST against incomplete docs.',
208
- 'GTmetrix 45 → 97. Page load ~25% faster: code splitting, lazy loading, assets.',
209
- ],
210
- stack: ['React', 'Next.js', 'Tailwind', 'Payment APIs'],
151
+ title: 'Dependency audit',
152
+ body: 'Zero dependencies. Still dependent on someone replying.',
211
153
  },
212
154
  ];
213
155
 
214
- export const cases = [
215
- {
216
- id: 'crons',
217
- title: 'The deploy that killed 17 cron jobs',
218
- company: 'TPK Synergy Limited',
219
- role: 'Senior Full Stack Engineer',
220
- kind: 'Reliability · private production',
221
- problem:
222
- 'After every deploy, recurring Amazon sync jobs died quietly. Dashboards looked fine until the data was a day old.',
223
- trace: [
224
- 'BullMQ job state — work marked active, then gone',
225
- 'ioredis connections closing with the Node process',
226
- 'Node shutdown: no drain, no SIGTERM handler that waited',
227
- 'Railway scheduler: the next tick assumed the last run had finished',
228
- ],
229
- change: 'Explicit shutdown handling across 17 cron processes so a deploy could not leave work stranded.',
230
- result: 'Sync jobs survive deploys instead of going silent until a seller notices bad numbers.',
231
- still:
232
- 'A worker that exits without draining still looks healthy on the dashboard. The failure mode is silence, not a page. That is what I watch for.',
233
- undo: 'I would put a queue-depth and last-success timestamp on the dashboard the same week. Shutdown handling without a visible lag is how the next silent failure hides.',
234
- ask: [
235
- 'What did SIGTERM actually do before the patch?',
236
- 'How did you know it was 17 processes, not one flaky job?',
237
- 'What still pages you, and what still does not?',
238
- ],
239
- stack: ['Node.js', 'BullMQ', 'Redis', 'Railway'],
240
- },
241
- {
242
- id: 'reports',
243
- title: 'Reports that used to take two hours',
244
- company: 'Mereb Technologies',
245
- role: 'Senior Software Engineer',
246
- kind: 'Performance · private production',
247
- problem: 'Reporting on about a million records ran for two hours and blocked people who needed the numbers.',
248
- trace: [
249
- 'Ad-hoc application paths doing aggregation the database should do',
250
- 'Missing indexes, no read replica, cache that did not cover the hot report',
251
- 'Status-checking that polled work already in flight',
252
- ],
253
- change:
254
- 'Indexing, read/write separation, caching, and a redesigned status-checking flow so the database did the aggregation.',
255
- result:
256
- 'Same reports, under 30 minutes. Platform performance up about 30%. Infra cost down about 35% (~$100K/year, engineering estimate).',
257
- still: 'I cannot show the queries here. They are in a private repo. I will walk through them on a call.',
258
- undo: 'I would have split the hottest report into an async job on day one instead of waiting until it was a two-hour meeting blocker.',
259
- ask: [
260
- 'Which index moved the needle, and how did you measure it?',
261
- 'What did you refuse to cache?',
262
- 'Where does the 30% / $100K estimate come from?',
263
- ],
264
- stack: ['PostgreSQL', 'PHP', 'Redis', 'AWS'],
265
- },
266
- {
267
- id: 'nedaj',
268
- title: 'Nedaj payments',
269
- company: 'Eaglelion Systems Technology',
270
- role: 'Frontend Developer',
271
- kind: 'Shipped product · client rails',
272
- problem:
273
- 'A fuel-purchase app had to talk to Mastercard, Visa, Telebirr, and commercial banks, with incomplete provider docs and a SOAP past.',
274
- trace: [
275
- 'I built the client payment workflows: states, callbacks, failures, retries, verification',
276
- 'Not the acquiring bank, not the ledger, not the 3B ETB as a personal metric — the products handled that volume',
277
- 'SOAP replaced with REST against docs that did not match production',
278
- ],
279
- change: 'Reliable payment states on a product with 100K+ downloads. Live on Google Play.',
280
- result: 'Payment rails that hold up in production. GTmetrix 45 → 97 on the web surface.',
281
- still: 'Provider callbacks still fail in ways the docs do not mention. Retries and verification are the job.',
282
- undo: 'I would have treated provider docs as hostile from the first week and recorded every callback mismatch in one log, not in Slack.',
283
- ask: [
284
- 'What did you own on the client, and what did you not own?',
285
- 'How did a failed Telebirr callback become a user-visible state?',
286
- 'Why is 3B ETB not your personal metric?',
287
- ],
288
- stack: ['React', 'Next.js', 'REST', 'Payment APIs'],
289
- url: 'https://play.google.com/store/apps/details?id=com.eaglelionsystems.nedaj',
290
- },
291
- ];
292
-
293
- export const work = cases;
294
-
295
- export const mentorship = {
296
- where: 'Mereb Technologies',
297
- who: '3 junior engineers',
298
- on: ['API development', 'debugging', 'code quality', 'system design'],
299
- still: 'They can speak to the reviews. I will not invent a quote for them. Ask me who, and I will name the work.',
300
- };
301
-
302
156
  export const skills = [
303
157
  {
304
158
  name: 'Dangerous at',
package/src/fit.js CHANGED
@@ -37,5 +37,5 @@ export const tweetFromReport = (report) => {
37
37
  if (report && typeof report.score === 'number') {
38
38
  return `this posting scored ${report.score}/100\nnpx jobless lint`;
39
39
  }
40
- return 'npx jobless';
40
+ return 'Got invited to interview and rejected at the same time.\nFiled it as a race condition.\nShipped the reproduction: npx jobless';
41
41
  };
package/src/hiring.js ADDED
@@ -0,0 +1,70 @@
1
+ import { detectIdentity } from './identity.js';
2
+ import { copyToClipboard } from './clip.js';
3
+ import { blank, bold, dim, green, print, red, sleep, wrap } from './style.js';
4
+
5
+ const scenarios = [
6
+ {
7
+ accepted: "Congratulations! We'd love to interview you.",
8
+ rejected: "Unfortunately, we're moving forward with others.",
9
+ detail: 'Interview scheduled successfully.\nCandidate rejected successfully.',
10
+ },
11
+ {
12
+ accepted: 'Your profile is exactly what we are looking for.',
13
+ rejected: 'Your profile does not match what we are looking for.',
14
+ detail: 'Requirements unchanged.\nReality updated successfully.',
15
+ },
16
+ {
17
+ accepted: 'Please choose a time to meet the team.',
18
+ rejected: 'We will not be meeting at this time.',
19
+ detail: 'Calendar invite delivered.\nClosure marked as spam.',
20
+ },
21
+ ];
22
+
23
+ export const demoPosting = `DemoCorp is hiring a Senior Software Engineer.
24
+ Competitive salary. We are a family looking for a rockstar.
25
+ Six interview rounds. Unpaid take-home: build our next product.
26
+ Remote, but must be in the office three days a week.
27
+ Node.js, PostgreSQL, React. Nights and weekends expected.`;
28
+
29
+ export const hiringResult = ({ seed = 0, personalize = false } = {}) => {
30
+ const numeric = Number(seed);
31
+ if (!Number.isSafeInteger(numeric)) throw new Error('--seed needs a whole, safe integer');
32
+ const scenario = scenarios[((numeric % scenarios.length) + scenarios.length) % scenarios.length];
33
+ const greeting = personalize ? `Candidate: ${detectIdentity({ personalize: true }).name}\n\n` : '';
34
+ return `${greeting}✔ ${scenario.accepted}\n✔ ${scenario.rejected}\n\n` +
35
+ 'ERR_HIRING_RACE_CONDITION\nApplication status: yesn\'t\n\n' +
36
+ `${scenario.detail}\nAll systems operational.`;
37
+ };
38
+
39
+ export const shareText = (result) => `${result}\n\nReproduce the hiring process: npx jobless`;
40
+
41
+ export const printShare = (text, { copy = false } = {}) => {
42
+ print(text);
43
+ if (copy) {
44
+ blank();
45
+ print(copyToClipboard(text) ? 'Copied. Send it to someone waiting for a reply.' : 'Clipboard unavailable. Copy the text above.');
46
+ }
47
+ };
48
+
49
+ export const printHiring = async ({ seed = 0, personalize = false, animate = false } = {}) => {
50
+ const result = hiringResult({ seed, personalize });
51
+ blank();
52
+ for (const line of wrap('jobless / hiring simulator / satire')) print(dim(` ${line}`));
53
+ blank();
54
+ for (const line of result.split('\n')) {
55
+ const color = line.startsWith('✔') ? green : line.startsWith('ERR_') ? red : bold;
56
+ for (const row of wrap(line)) print(` ${color(row)}`);
57
+ if (animate && line.startsWith('✔')) await sleep(300);
58
+ }
59
+ blank();
60
+ for (const line of wrap("I couldn't fix their hiring pipeline. So I shipped my own.")) print(` ${line}`);
61
+ for (const line of wrap('Built by Yonas Alem. Meet the engineer: npx jobless hire')) print(dim(` ${line}`));
62
+ return result;
63
+ };
64
+
65
+ export const printPlayHints = () => {
66
+ blank();
67
+ for (const line of ['[r] replay [d] demo [l] lint a posting', '[c] copy result [h] meet the author [q] quit']) {
68
+ for (const row of wrap(line)) print(` ${dim(row)}`);
69
+ }
70
+ };
package/src/identity.js CHANGED
@@ -40,7 +40,11 @@ const companyFromDomain = (domain) => {
40
40
  .join(' ');
41
41
  };
42
42
 
43
- export const detectIdentity = () => {
43
+ export const detectIdentity = ({ personalize = false } = {}) => {
44
+ if (!personalize) {
45
+ return { name: 'Candidate', email: 'candidate@example.invalid', username: 'candidate',
46
+ domain: 'example.invalid', company: 'your team', cwd: '', host: '' };
47
+ }
44
48
  const username = os.userInfo()?.username || process.env.USER || process.env.USERNAME || 'candidate';
45
49
  const email = git('user.email') || `${username}@local`;
46
50
  const name = git('user.name') || titleCase(username);
@@ -0,0 +1,73 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { lintResult } from './lint.js';
3
+ import { letterText } from './reject.js';
4
+ import { demoPosting, hiringResult, printHiring, printPlayHints, printShare, shareText } from './hiring.js';
5
+ import { blank, clear, dim, print, showCursor } from './style.js';
6
+ import { printHeader, printLintReport, printMenu } from './screens.js';
7
+
8
+ const readPaste = async (lines) => {
9
+ print(dim(' Paste a posting. An empty line finishes it.'));
10
+ const posting = [];
11
+ while (true) {
12
+ const { value, done } = await lines.next();
13
+ if (done || value === '') break;
14
+ posting.push(value);
15
+ }
16
+ return posting.join('\n');
17
+ };
18
+
19
+ export const interactive = async ({ trap, seed = 0, personalize, resolveCommand, runCommand, showResume }) => {
20
+ // Keep one input iterator so pasted lines and EOF are not lost between prompts.
21
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
22
+ const lines = rl[Symbol.asyncIterator]();
23
+ let lastReport = null;
24
+ let lastResult = '';
25
+ let nextSeed = Number(seed);
26
+ try {
27
+ clear();
28
+ if (trap) {
29
+ await printHiring({ seed: nextSeed, personalize, animate: true });
30
+ lastResult = shareText(hiringResult({ seed: nextSeed }));
31
+ printPlayHints();
32
+ } else showResume();
33
+
34
+ while (true) {
35
+ process.stdout.write(dim(' jobless> '));
36
+ const { value, done } = await lines.next();
37
+ if (done) break;
38
+ const command = resolveCommand(value);
39
+ if (command === 'quit') {
40
+ blank();
41
+ print(dim(' still shipping. still waiting for a reply.'));
42
+ break;
43
+ }
44
+ if (command === 'replay') {
45
+ lastReport = null;
46
+ nextSeed = (nextSeed % 3) + 1;
47
+ clear();
48
+ await printHiring({ seed: nextSeed, personalize, animate: true });
49
+ lastResult = shareText(hiringResult({ seed: nextSeed }));
50
+ printPlayHints();
51
+ } else if (command === 'copy' || command === 'share') {
52
+ if (lastResult) printShare(lastResult, { copy: command === 'copy' });
53
+ else print(dim(' Try r for a hiring result or d for the posting demo.'));
54
+ } else if (command === 'lint' || command === 'demo') {
55
+ const text = command === 'demo' ? demoPosting : await readPaste(lines);
56
+ clear();
57
+ lastReport = lintResult(text, command === 'demo' ? 'demo' : 'paste');
58
+ printLintReport(lastReport);
59
+ lastResult = lastReport.letter ? `${letterText(lastReport.letter)}\n\nLint the posting: npx jobless lint` :
60
+ `Posting passed the satire rules: ${lastReport.score}/100\nnpx jobless lint`;
61
+ printPlayHints();
62
+ } else if (command) {
63
+ clear();
64
+ if (!['help', 'why', 'tweet', 'rant'].includes(command)) printHeader();
65
+ runCommand(command, lastReport);
66
+ printMenu();
67
+ } else print(dim(' Try r, d, l, c, h, help, or q.'));
68
+ }
69
+ } finally {
70
+ rl.close();
71
+ showCursor();
72
+ }
73
+ };
@@ -0,0 +1,128 @@
1
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { stdin } from 'node:process';
4
+ import { unifiedDiff } from './diff.js';
5
+ import { fixJobPost, githubAnnotations, lintResult } from './lint.js';
6
+ import { demoPosting } from './hiring.js';
7
+ import { blank, dim, green, print } from './style.js';
8
+ import { copyLetter, printFix, printHelp, printLintReport, printTweet } from './screens.js';
9
+
10
+ const readStdin = async () => {
11
+ const chunks = [];
12
+ for await (const chunk of stdin) chunks.push(chunk);
13
+ return Buffer.concat(chunks).toString('utf8');
14
+ };
15
+
16
+ const POSTING_RE = /\.(md|txt|markdown)$/i;
17
+
18
+ const walkPostings = (dir, acc = []) => {
19
+ for (const name of readdirSync(dir).sort()) {
20
+ if (name.startsWith('.')) continue;
21
+ const path = join(dir, name);
22
+ let st;
23
+ try {
24
+ st = statSync(path);
25
+ } catch {
26
+ continue;
27
+ }
28
+ if (st.isDirectory()) walkPostings(path, acc);
29
+ else if (POSTING_RE.test(name)) acc.push(path);
30
+ }
31
+ return acc;
32
+ };
33
+
34
+ const collectPostings = (paths) => {
35
+ const files = [];
36
+ for (const path of paths) {
37
+ const st = statSync(path);
38
+ if (st.isDirectory()) walkPostings(path, files);
39
+ else files.push(path);
40
+ }
41
+ return files;
42
+ };
43
+
44
+ const lintOne = (text, source, { off, fix, write }) => {
45
+ const report = lintResult(text, source, { off });
46
+ if (fix || write) {
47
+ const fixed = fixJobPost(text, { off });
48
+ report.fix = {
49
+ applied: fixed.applied,
50
+ text: fixed.text,
51
+ path: source,
52
+ before: text,
53
+ diff: unifiedDiff(text, fixed.text, source === 'stdin' || source === 'paste' ? 'posting.txt' : source),
54
+ };
55
+ if (write && source !== 'stdin' && source !== 'paste' && fixed.applied.length) {
56
+ writeFileSync(source, fixed.text);
57
+ report.fix.wrote = true;
58
+ }
59
+ }
60
+ return report;
61
+ };
62
+
63
+ export const runLintCli = async (files, options) => {
64
+ const { format = 'pretty', fix = false, write = false, copy = false, quiet = false, off = [], maxWarnings, tweet = false, demo = false } = options;
65
+
66
+ const inputs = [];
67
+ if (demo) {
68
+ if (files.length || write) throw new Error('--demo cannot be combined with files or --write');
69
+ inputs.push({ source: 'demo', text: demoPosting });
70
+ } else if (files[0]) {
71
+ const paths = collectPostings(files);
72
+ if (!paths.length) {
73
+ print(dim(` no .md/.txt postings under ${files.join(', ')}`));
74
+ process.exitCode = 1;
75
+ return;
76
+ }
77
+ for (const path of paths) {
78
+ inputs.push({ source: path, text: readFileSync(path, 'utf8') });
79
+ }
80
+ } else if (!stdin.isTTY) {
81
+ inputs.push({ source: 'stdin', text: await readStdin() });
82
+ } else {
83
+ printHelp();
84
+ blank();
85
+ print(dim(' lint needs a file, a directory, a pipe, or an interactive paste: npx jobless lint jobs/'));
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+
90
+ const reports = inputs.map((input) => lintOne(input.text, input.source, { off, fix: fix || write, write }));
91
+ const lastReport = reports.at(-1);
92
+
93
+ let failed = false;
94
+ for (const report of reports) {
95
+ if (!report.ok) failed = true;
96
+ if (maxWarnings != null && report.warnings > maxWarnings) failed = true;
97
+ }
98
+ if (failed) process.exitCode = 1;
99
+
100
+ if (format === 'json') {
101
+ print(JSON.stringify(reports.length === 1 ? reports[0] : { reports }, null, 2));
102
+ if (copy && lastReport?.letter) copyLetter(lastReport.letter);
103
+ return;
104
+ }
105
+
106
+ if (format === 'github') {
107
+ const body = reports.map((report) => githubAnnotations(report)).filter(Boolean).join('\n');
108
+ if (body) print(body);
109
+ return;
110
+ }
111
+
112
+ for (const [index, report] of reports.entries()) {
113
+ if (index) blank();
114
+ const copied = copy && report.letter ? copyLetter(report.letter) : false;
115
+ printLintReport(report, { quiet, copied });
116
+ if (report.fix?.applied.length) printFix(report.fix);
117
+ if (report.fix?.wrote) print(` ${green('wrote')} ${report.source}`);
118
+ }
119
+
120
+ if (tweet) {
121
+ blank();
122
+ printTweet(lastReport);
123
+ }
124
+
125
+ blank();
126
+ print(dim(' more: npx jobless · npx jobless hire'));
127
+ };
128
+
@@ -0,0 +1,114 @@
1
+ import { unifiedDiff } from './diff.js';
2
+ import { rules } from './lint.js';
3
+ import { printCompanyReject } from './reject.js';
4
+ import {
5
+ blank,
6
+ bold,
7
+ dim,
8
+ green,
9
+ print,
10
+ red,
11
+ section,
12
+ yellow,
13
+ } from './style.js';
14
+
15
+ export const printLintReport = (report, { quiet = false, copied = false } = {}) => {
16
+ const { findings, source, errors, warnings, letter, ok, score } = report;
17
+ const visible = quiet ? findings.filter((item) => item.level === 'error') : findings;
18
+
19
+ blank();
20
+ section('JOBLESS LINT');
21
+ print(dim(' A rule-based satire score, not an employer rating.'));
22
+ blank();
23
+ print(` ${dim(source)} ${dim('·')} ${bold(`${score}/100`)}`);
24
+ blank();
25
+
26
+ if (ok && !findings.length) {
27
+ print(` ${green('clean')} this posting might be a real job.`);
28
+ if (report.fit) printFit(report.fit, { failed: false });
29
+ blank();
30
+ print(` ${dim('If you still want the engineer who wrote the linter:')} ${bold(green('h'))}`);
31
+ return report;
32
+ }
33
+
34
+ for (const finding of visible) {
35
+ const tag = finding.level === 'error' ? red('error') : yellow('warn ');
36
+ print(` ${tag} ${finding.message}`);
37
+ print(` ${dim(finding.id)}`);
38
+ if (finding.line && finding.snippet) {
39
+ print(` ${dim(`L${finding.line}`)} ${finding.snippet}`);
40
+ }
41
+ blank();
42
+ }
43
+
44
+ printCompanyReject(letter);
45
+ if (report.fit) printFit(report.fit, { failed: true });
46
+ blank();
47
+ print(
48
+ ` ${bold(`${score}/100`)} ${errors} error${errors === 1 ? '' : 's'} ${warnings} warning${warnings === 1 ? '' : 's'} ${dim('·')} ${dim('hire:')} ${bold(green('h'))}${dim(' copy letter:')} ${bold('c')}`,
49
+ );
50
+ if (copied) {
51
+ print(` ${green('copied')} rejection letter`);
52
+ }
53
+ return report;
54
+ };
55
+
56
+ export const printFit = (fit, { failed = false } = {}) => {
57
+ if (!fit) return;
58
+ blank();
59
+ section('FIT');
60
+ blank();
61
+ if (fit.matched.length) print(` ${green('overlap')} ${fit.matched.join(dim(' · '))}`);
62
+ if (fit.missed.length) print(` ${yellow('miss')} ${fit.missed.join(dim(' · '))}`);
63
+ if (fit.extra.length) print(` ${dim('also')} ${fit.extra.join(' · ')}`);
64
+ blank();
65
+ if (fit.score == null) {
66
+ print(` ${dim('The posting did not name a stack. The engineer still exists.')} ${bold(green('h'))}`);
67
+ } else if (failed) {
68
+ print(
69
+ ` ${bold(`${fit.score}/100`)} overlap ${dim('·')} ${dim('the posting failed. the engineer still matches:')} ${bold(green('h'))}`,
70
+ );
71
+ } else {
72
+ print(
73
+ ` ${bold(`${fit.score}/100`)} overlap ${dim('·')} ${dim('hire the engineer who matches:')} ${bold(green('h'))}`,
74
+ );
75
+ }
76
+ };
77
+
78
+ export const printFix = (fixed) => {
79
+ blank();
80
+ section('DIFF');
81
+ blank();
82
+ if (!fixed.applied.length) {
83
+ print(` ${dim('nothing to rewrite.')}`);
84
+ return;
85
+ }
86
+ print(` ${dim('applied')} ${fixed.applied.join(' · ')}`);
87
+ blank();
88
+ const diff = fixed.diff || unifiedDiff(fixed.before || '', fixed.text, fixed.path || 'posting.txt');
89
+ if (!diff) {
90
+ print(` ${dim('rewrite produced the same lines.')}`);
91
+ return;
92
+ }
93
+ for (const line of diff.split('\n')) {
94
+ if (line.startsWith('+') && !line.startsWith('+++')) print(` ${green(line)}`);
95
+ else if (line.startsWith('-') && !line.startsWith('---')) print(` ${red(line)}`);
96
+ else print(` ${dim(line)}`);
97
+ }
98
+ };
99
+
100
+ export const printRules = () => {
101
+ blank();
102
+ section('RULES');
103
+ blank();
104
+ for (const rule of rules) {
105
+ const tag = rule.level === 'error' ? red('error') : yellow('warn ');
106
+ print(` ${tag} ${rule.id}`);
107
+ print(` ${dim(rule.message)}`);
108
+ if (rule.why) print(` ${dim(rule.why)}`);
109
+ blank();
110
+ }
111
+ print(` ${dim('npx jobless lint --off=rockstar-ninja posting.txt')}`);
112
+ print(` ${dim('jobless-disable-next-line no-fake-salary')}`);
113
+ };
114
+