Build a Role Deficiency Analyzer CLI

Build a TypeScript CLI that scores job postings across five deficiency dimensions.

Introduction

30 Second Summary

You see a job posting that asks for a senior engineer with startup founding experience, distributed systems chops, and AI expertise. The company has 26 people and just raised a seed round. The best candidates glance at that mismatch, do the math in their heads, and never apply.

In this project, you will build a TypeScript CLI tool that automates that mental math. You feed it a job description and public company signals, and it outputs a scored deficiency report declaring exactly why qualified candidates walk away.

What You'll Build

You'll run a single command in your terminal and watch it produce a formatted markdown report that scores a real job posting across five structural dimensions, each backed by cited evidence and a plain-English verdict.

By the end of this project, you'll have:

  • A TypeScript CLI you can run with a single command that reads a job description file and a company context file, then prints scored verdicts directly to your terminal.
  • A five-dimension scoring framework that evaluates candidate-market mismatch, revenue maturity, moat fragility, user economics, and hiring friction. Each dimension produces a numeric score, a verdict, and cited evidence.
  • A formatted markdown report generated automatically with score bar visualizations, bullet-pointed evidence, and a synthesized declaration explaining why the role stays unfilled.
  • Secret Mission: Add steelman counter-arguments that acknowledge the company's bull case for each flaw, turning the report into a balanced decision document you could hand to a hiring manager.

Are there any prerequisites?

You should have Node.js and npm installed on your machine. Basic familiarity with TypeScript (or JavaScript) is helpful but not required. The project walks you through every file.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Committing to the goal now means every line of code you write has a clear purpose behind it.

Set Up the Project

A deficiency analyzer needs fast iteration. You'll be tweaking scoring logic, adjusting inputs, and re-running constantly. A traditional TypeScript build step (compile first, then run) adds friction to every single cycle.

In this step, you'll set up a project that uses tsx to execute TypeScript files directly. No compile step, no dist folder. You save the file, you run it, you see results.

In this step, get ready to:
  • Create and initialize the project directory.
  • Install TypeScript and tsx for direct execution.
  • Create the entry point and confirm it runs.
Create the project directory

Every project starts with a dedicated folder. This keeps your files isolated and gives npm a clean workspace to track dependencies.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir role-signal
cd role-signal
  • Initialize the project with npm by running:
npm init -y

You should see a confirmation message showing the generated package.json contents printed to the terminal.

  • Confirm the file was created by running:
ls

You should see package.json listed in the output.

Don't see package.json?

Make sure you ran npm init -y from inside the role-signal folder. Run pwd to check your current directory. It should end with role-signal.

help me troubleshoot why npm init didn't create package.json in my role-signal folder

  • Open your project in your code editor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code (or Cursor if that's your editor) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the role-signal folder on your Desktop, then click Open.

You should see package.json in the Explorer sidebar on the left.

Install dependencies and configure TypeScript

The project needs two packages. tsx runs TypeScript directly without a separate compile step. TypeScript itself provides the type-checking engine.

  • Install both packages as dev dependencies by running this command in your terminal:
npm install tsx@4.23.1 typescript@5.8.3 --save-dev

You should see output confirming two packages were added. A node_modules folder now exists in your project.

Seeing permission errors or network failures?

If you see EACCES errors, your npm installation may need permissions fixed. If you see ENOTFOUND, check your internet connection.

help me fix npm install errors in my role-signal project

Now you need a tsconfig.json to tell TypeScript how to interpret your code. This file configures strict type checking and ESModule syntax.

  • Create a new file called tsconfig.json in the root of your role-signal folder (right-click in the Explorer sidebar and select New File).
  • Paste the following configuration into it:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

What does this configuration do?

  • "strict": true enables all strict type-checking options. TypeScript will catch more bugs at write-time instead of runtime.
  • "module": "ESNext" tells TypeScript to use modern ES module syntax (import/export) instead of older CommonJS (require).
  • "moduleResolution": "bundler" lets you use .js extensions in imports, which tsx requires for ESM resolution.
  • Save tsconfig.json.
  • Confirm the file exists by running:
ls

You should see node_modules, package.json, package-lock.json, and tsconfig.json listed.

Don't see tsconfig.json in the list?

Make sure you created the file at the root level of role-signal, not inside node_modules or another subfolder. Check the Explorer sidebar to confirm its position.

help me figure out where my tsconfig.json ended up

✔️ Awesome, I've got everything!

Great. Make sure you've saved tsconfig.json before moving on.

ⓧ I'd like to double check the full code

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
Create the entry point and verify the tool runs

With the project configured, you need an entry file that tsx will execute. You also need to update package.json to declare this as an ES module project with a run script.

  • Create a src folder inside role-signal (right-click in the Explorer sidebar and select New Folder).
  • Inside src, create a file called analyze.ts.
  • Paste this single line into src/analyze.ts:
console.log('role-signal ready');
  • Save src/analyze.ts.

Now update package.json so Node.js treats the project as an ES module and includes a convenient run script.

  • Open package.json in your editor.
  • Replace its entire contents with this:
{
  "name": "role-signal",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "analyze": "tsx src/analyze.ts"
  },
  "devDependencies": {
    "tsx": "4.23.1",
    "typescript": "5.8.3"
  }
}

What changed in package.json?

  • "type": "module" tells Node.js to use ES module syntax for all .js and .ts files in this project. Without it, import statements would fail.
  • "analyze": "tsx src/analyze.ts" creates a shortcut so you can run your tool with npm run analyze or npx tsx src/analyze.ts directly.
  • The extra fields from npm init (like main, keywords, license) are removed because this is a CLI tool, not a published package.
  • Save package.json.

Seeing a JSON parse error in your editor?

Make sure every string value has matching double quotes and every key-value pair is separated by a comma (except the last one in each block). JSON does not allow trailing commas.

help me fix a JSON syntax error in my package.json

✔️ Awesome, I've got everything!

Great. Make sure both package.json and src/analyze.ts are saved before running the tool.

ⓧ I'd like to double check the full code

Here is the final package.json:

{
  "name": "role-signal",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "analyze": "tsx src/analyze.ts"
  },
  "devDependencies": {
    "tsx": "4.23.1",
    "typescript": "5.8.3"
  }
}

And here is src/analyze.ts:

console.log('role-signal ready');

Before you run this, what do you expect to see printed in the terminal?

  • Run your tool by executing this command:
npx tsx src/analyze.ts

You should see role-signal ready printed to your terminal. That confirms tsx is executing your TypeScript file directly without any compile step.

Seeing 'Cannot find module' or 'ERR_MODULE_NOT_FOUND'?

Check that your file is at src/analyze.ts (inside a src folder), not at the root level. Also verify that package.json has "type": "module" set.

help me debug why npx tsx src/analyze.ts can't find my module

Your TypeScript environment is ready for fast iteration. Next up, you'll define the input schema and seed it with real data from the NextWork job posting.

Define Input Schema and Seed the NextWork Case

Your CLI tool runs, but it has no data to analyze. A scoring engine without structured inputs is just opinion dressed up as analysis.

In this step, you'll define TypeScript interfaces that describe the shape of a job description and company context. Then you'll seed the tool with the actual NextWork Senior Product Engineer posting and publicly available company signals.

In this step, get ready to:
  • Define TypeScript interfaces for job descriptions, company context, and scoring output.
  • Create JSON data files with real evidence from the NextWork job posting and public signals.
  • Wire the inputs into the analyzer and confirm it parses everything without errors.
Define the TypeScript interfaces

Every piece of data your scoring engine touches needs a defined shape. TypeScript interfaces enforce that your JD data, company signals, and scoring output all match the structure your code expects.

  • Create a new file called src/types.ts and add the input interfaces by pasting this code:
export interface JobDescription {
  title: string;
  company: string;
  senioritySignals: string[];
  requiredSkills: string[];
  responsibilities: string[];
  candidateProfile: string[];
  bonusSignals: string[];
}

export interface CompanyContext {
  fundingTotal: string;
  lastRound: { amount: string; date: string; lead: string };
  teamSize: number;
  hqLocation: string;
  revenueEvidence: string[];
  userMetrics: { count: string; growth: string; retention: string };
  competitiveThreats: { incumbent: string; capability: string; launchDate: string }[];
  rolePostedDuration: string;
  productModel: string;
}

What do these interfaces describe?

These are the two inputs your tool ingests.

  • JobDescription captures what the role asks for: seniority signals, skills, responsibilities, and the ideal candidate profile.
  • CompanyContext captures publicly observable company signals: funding, team size, revenue evidence, competitive threats, and user retention patterns.
  • Now add the output interfaces below the CompanyContext interface in the same file:
export interface DimensionScore {
  dimension: string;
  score: number;
  verdict: string;
  evidence: string[];
}

export interface AnalysisResult {
  jd: JobDescription;
  context: CompanyContext;
  dimensions: DimensionScore[];
  deficiencyIndex: number;
  declaration: string;
}

What are DimensionScore and AnalysisResult for?

  • DimensionScore is what each scoring function returns: a numeric score, a plain-English verdict, and the evidence that supports it.
  • AnalysisResult wraps the full analysis: all five dimension scores plus an aggregate deficiency index and a summary declaration.
  • Save src/types.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

export interface JobDescription {
  title: string;
  company: string;
  senioritySignals: string[];
  requiredSkills: string[];
  responsibilities: string[];
  candidateProfile: string[];
  bonusSignals: string[];
}

export interface CompanyContext {
  fundingTotal: string;
  lastRound: { amount: string; date: string; lead: string };
  teamSize: number;
  hqLocation: string;
  revenueEvidence: string[];
  userMetrics: { count: string; growth: string; retention: string };
  competitiveThreats: { incumbent: string; capability: string; launchDate: string }[];
  rolePostedDuration: string;
  productModel: string;
}

export interface DimensionScore {
  dimension: string;
  score: number;
  verdict: string;
  evidence: string[];
}

export interface AnalysisResult {
  jd: JobDescription;
  context: CompanyContext;
  dimensions: DimensionScore[];
  deficiencyIndex: number;
  declaration: string;
}
Create the input data files

Your interfaces define the shape. Now you need the actual data. You'll create two JSON files containing the NextWork Senior Product Engineer posting and publicly observable company signals.

  • Create a data/ directory inside your role-signal folder by running this command:
mkdir data
  • Create a new file called data/nextwork-jd.json and paste in the first half of the JD data:
{
  "title": "Senior Product Engineer",
  "company": "NextWork",
  "senioritySignals": [
    "First or early engineer at a startup",
    "Consistent scope/title progression over time",
    "Owned a core product domain end-to-end",
    "Defined the problem, drove strategy, owned the roadmap",
    "Cross-functional - shipped with product and design under real constraints"
  ],
  "requiredSkills": [
    "GCP",
    "Go or Node/TS",
    "Scalability and reliability",
    "Core data model work",
    "Content creation tooling",
    "Project automation and validations"
  ]
}

What is this data?

This is the structured version of the job posting's requirements. The senioritySignals array captures what the role implicitly demands in terms of career stage. The requiredSkills array captures the explicit tech stack requirements.

  • Now replace the closing } with a comma, and add the remaining fields below the requiredSkills array:
  "responsibilities": [
    "Own and evolve backend systems: scalability, reliability, performance, developer experience",
    "Lead core data model work",
    "Reduce tech debt and improve critical paths",
    "Build tooling/services for content creation, project automation, validations, and documentation",
    "Collaborate end-to-end with product and frontend",
    "Collaborate with non-technical members of the team",
    "Speak directly with learners in the community"
  ],
  "candidateProfile": [
    "First or early engineer at a startup - built something from zero to one",
    "Consistent scope/title progression over time",
    "Owned a core product domain (not just features) end-to-end",
    "Proactively identifies product or system problems without being told",
    "Clear, measurable impact in previous roles",
    "Comfortable with ambiguity",
    "Cross-functional - shipped with product and design under real constraints",
    "Pragmatic about tradeoffs: build vs. buy, boring tech, fast V1s",
    "Recent experience on small or early-stage teams",
    "Positive, practical adoption of AI in their workflow"
  ],
  "bonusSignals": [
    "RAG experience",
    "SEO awareness",
    "Graph-based data modeling"
  ]
}

Why separate candidateProfile from senioritySignals?

The candidateProfile array captures the JD's explicit "strong candidate signals" section. These are the 10 traits the posting says it wants. Your scoring engine will compare what this profile commands in the market against what the company can offer.

  • Save data/nextwork-jd.json.
  • Confirm the file exists by running:
ls data/
  • You should see nextwork-jd.json in the output.

Don't see the file?

  • Make sure you created the data/ directory first with mkdir data.
  • Check that you saved the file inside data/, not in the project root.
  • help me find where my JSON file was saved

Now for the company context. This is where the evidence lives.

  • Create a new file called data/nextwork-context.json and paste the first section of company signals:
{
  "fundingTotal": "$9.1M across 4 rounds",
  "lastRound": {
    "amount": "$4.45M Seed",
    "date": "March 2026",
    "lead": "Shakti VC"
  },
  "teamSize": 26,
  "hqLocation": "Austin, Texas (relocated from New Zealand)",
  "revenueEvidence": [
    "Platform explicitly states 'NextWork is completely free'",
    "Terms of Service mention subscription fees for some features",
    "Pro badge exists (visible in changelog June 2025)",
    "No public pricing page (returns 500 error as of July 2026)",
    "No employer-facing product announced publicly",
    "No B2B partnerships disclosed",
    "Press releases focus on learner growth, not revenue metrics"
  ]
}

Where does this data come from?

Every entry in revenueEvidence is sourced from public pages: the NextWork website FAQ, their Terms of Service, press releases, and changelog posts. This is what makes the analysis evidence-based instead of speculative.

  • Replace the closing } with a comma, and add the user metrics, competitive threats, and product model below the revenueEvidence array:
  "userMetrics": {
    "count": "190,000+ learners in 190+ countries",
    "growth": "Organic, no paid acquisition disclosed",
    "retention": "one-shot: users build portfolio, get job, leave. No compounding value. LTV capped at 3-6 months."
  },
  "competitiveThreats": [
    {
      "incumbent": "LinkedIn Verified AI Skills",
      "capability": "Tool-usage-based skill verification directly on profiles, partnered with Replit, Lovable, Descript, GitHub",
      "launchDate": "January 2026"
    },
    {
      "incumbent": "Udemy Business Altus AI",
      "capability": "Agentic AI for measuring actual capability vs. course completions, enterprise B2B",
      "launchDate": "June 2026"
    },
    {
      "incumbent": "LinkedIn Learning + GitHub Codespaces",
      "capability": "Hands-on coding practice in real environments, portfolio-style courses",
      "launchDate": "2022 (expanded 2025-2026)"
    }
  ],
  "rolePostedDuration": "Extended period (reported by candidate)",
  "productModel": "Free project-based learning generates portfolio documentation. Revenue model is consumer Pro subscription (unknown price) + future employer-facing verification product (not yet built)."
}

Why track competitive threats?

The competitiveThreats array captures incumbents who already ship the capability the role is meant to build. LinkedIn launched Verified AI Skills in January 2026 with partners like Replit and GitHub. This data feeds the "Moat Fragility" scoring dimension later.

  • Save data/nextwork-context.json.
  • Confirm both files exist by running:
ls data/
  • You should see both nextwork-context.json and nextwork-jd.json listed.

Only see one file?

  • Check that you saved the context file inside the data/ directory, not the project root.
  • Ensure your JSON is valid. A missing comma after revenueEvidence will cause a save error in some editors.
  • help me fix my JSON data files
Wire inputs into the analyzer

You have the interfaces and the data. Now you need to connect them so the analyzer actually reads and parses the JSON files using your type definitions.

  • Open src/analyze.ts and replace the entire contents with this code:
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { JobDescription, CompanyContext } from "./types.js";

// Resolve paths to input data files
const jdPath = resolve("data/nextwork-jd.json");
const ctxPath = resolve("data/nextwork-context.json");

// Exit early if either input file is missing
if (!existsSync(jdPath) || !existsSync(ctxPath)) {
  console.error("Missing input files.");
  process.exit(1);
}

// Parse both JSON files with type annotations
const jd: JobDescription = JSON.parse(readFileSync(jdPath, "utf-8"));
const ctx: CompanyContext = JSON.parse(readFileSync(ctxPath, "utf-8"));

console.log(`JD: ${jd.title} at ${jd.company}`);
console.log(`Funding: ${ctx.fundingTotal}, Team: ${ctx.teamSize}`);

What does this code do?

  • resolve() builds absolute paths to the JSON files so the tool works regardless of where you run it from.
  • The existsSync check exits early with an error message if either file is missing, instead of crashing with an unreadable stack trace.
  • JSON.parse with the type annotation (: JobDescription) tells TypeScript to enforce your interface on the parsed data. Any field access that doesn't match the interface will error at compile time.
  • Save src/analyze.ts.

Before you run this, what do you expect to see printed? Think about what values jd.title and ctx.fundingTotal hold based on the JSON files you just created.

  • Run the analyzer by running this command:
npx tsx src/analyze.ts

You should see this output:

JD: Senior Product Engineer at NextWork
Funding: $9.1M across 4 rounds, Team: 26

The tool now reads real data and confirms the types line up. No parsing errors means your interfaces match the JSON structure exactly.

Seeing a parse error or "Missing input files"?

  • If you see "Missing input files", make sure you're running the command from the role-signal directory (not from inside src/ or data/).
  • If you see a JSON parse error, check for trailing commas or missing commas between fields in your JSON files. JSON does not allow trailing commas after the last item in an array or object.
  • If you see a TypeScript error about the import, make sure your import ends with ./types.js (not .ts). ESM requires the .js extension even for TypeScript source files.
  • help me debug my analyze.ts import or JSON parsing error

Your tool now ingests structured evidence. Next up, you'll build the scoring engine that converts this raw data into scored verdicts across five deficiency dimensions.

Build the Scoring Engine

You have structured inputs. The JD tells you what the role demands. The company context tells you what the company can actually deliver. But raw data sitting in JSON files isn't an analysis.

You need a scoring framework that converts evidence into scored verdicts. Each dimension must output a score from 1-10, a plain-English verdict, and the cited evidence that supports it. This is where the tool becomes more than a document.

In this step, get ready to:
  • Create five scoring functions that each evaluate a different deficiency dimension.
  • Build an orchestration engine that runs all scorers and computes an aggregate Deficiency Index.
  • Wire the engine into the analyzer to see scored results in the terminal.
Create the scoring module with the first two dimensions

Each scoring function takes the same two inputs (the job description and company context) and returns a DimensionScore object. The first dimension tackles the most fundamental flaw: do the people this JD describes actually exist at this price point?

  • Create a new file src/scoring.ts and add the import and first scoring function:
import { JobDescription, CompanyContext, DimensionScore } from "./types.js";

export function scoreCandidateMarketMismatch(jd: JobDescription, ctx: CompanyContext): DimensionScore {
  // Calculate the gap between what this candidate costs and what seed stage pays
  const seedStageMaxTC = 200000;
  const marketRateForProfile = 400000;
  const delta = marketRateForProfile - seedStageMaxTC;
  const score = Math.min(10, Math.round((delta / marketRateForProfile) * 10));

  return {
    dimension: "Candidate-Market Mismatch",
    score,
    verdict: `The described candidate commands $${marketRateForProfile.toLocaleString()}+ TC in today's market. A seed-stage company with ${ctx.fundingTotal} total funding can offer ~$${seedStageMaxTC.toLocaleString()} base + equity. The equity must 10-20x to close the gap, requiring a $500M+ exit that nothing in the fundamentals supports.`,
    evidence: [
      `JD requires: ${jd.senioritySignals.slice(0, 3).join(", ")}`,
      `Company stage: Seed, ${ctx.teamSize} employees, ${ctx.fundingTotal} total raised`,
      `Market rate for profile: $350-500K TC at FAANG/growth-stage`,
      `Implied comp band: $150-200K base + 0.3-0.8% equity`,
      `Delta: $${delta.toLocaleString()} annually in forgone cash compensation`,
    ],
  };
}

What does this function do?

It compares what a candidate matching this JD's seniority signals would earn in the open market ($350-500K total comp) against what a seed-stage company can realistically offer (~$200K base plus equity). The larger the gap, the higher the deficiency score.

The evidence array cites specific data points from the inputs so the final report can show its reasoning.

  • Add the revenue maturity scorer below the first function in src/scoring.ts:
export function scoreRevenueMaturity(jd: JobDescription, ctx: CompanyContext): DimensionScore {
  // Check for signals of real revenue vs. burning venture capital
  const signals = ctx.revenueEvidence;
  const hasPublicPricing = signals.some(s => s.includes("public pricing"));
  const hasB2BProduct = signals.some(s => s.includes("employer product"));
  const hasMeaningfulRevenue = signals.some(s => s.includes("ARR"));

  let score = 8;
  if (hasPublicPricing) score -= 2;
  if (hasB2BProduct) score -= 3;
  if (hasMeaningfulRevenue) score -= 3;

  return {
    dimension: "Revenue Maturity",
    score: Math.max(1, Math.min(10, score)),
    verdict: `The platform is explicitly free. Terms of Service confirm subscription fees exist but no public pricing is available. No employer-facing product exists publicly. Revenue is likely a thin consumer "Pro" tier converting at low single-digit percentages of 190K users. The company is pre-meaningful-revenue, burning venture capital.`,
    evidence: signals,
  };
}

What does the revenue scorer check?

It starts at a high deficiency score (8) and only reduces it if the company shows concrete revenue signals: public pricing, a B2B product, or disclosed ARR. Each missing signal keeps the score high. The revenue evidence array from the context file drives this classification.

  • Save src/scoring.ts.
  • Verify the module parses cleanly by running:
npx tsx src/scoring.ts

The command should exit immediately with no output and no errors. The file only exports functions, so a clean exit means TypeScript is happy with your types and syntax.

Seeing a type error?

Check that your import path ends with ./types.js (not ./types.ts). ESM resolution in tsx requires the .js extension even though the source file is .ts.

help me debug a TypeScript import resolution error in my scoring.ts file

Add the remaining three scoring dimensions

The next three dimensions evaluate structural risks that go beyond compensation: whether the moat is defensible, whether the user model compounds, and whether the hiring bar matches the stage.

  • Add the moat fragility scorer below scoreRevenueMaturity in src/scoring.ts:
export function scoreMoatFragility(jd: JobDescription, ctx: CompanyContext): DimensionScore {
  // Score based on how many incumbents already ship the claimed differentiator
  const threats = ctx.competitiveThreats;
  const score = Math.min(10, threats.length * 3 + 2);

  const threatDescriptions = threats.map(
    t => `${t.incumbent}: ${t.capability} (launched ${t.launchDate})`
  );

  return {
    dimension: "Moat Fragility",
    score,
    verdict: `The claimed differentiator ("verification layer for AI skills") is already being shipped by LinkedIn with 1B+ profiles and direct recruiter integration. Udemy Business is pivoting to capability measurement with their Altus AI. NextWork's 190K learners compete against incumbents with 1000x the distribution and existing employer revenue.`,
    evidence: threatDescriptions,
  };
}

How does moat fragility scoring work?

Each competitive threat in the context data adds 3 points to the base score of 2. With three incumbents already shipping similar capabilities, the score hits the ceiling. The evidence array formats each threat with its name, capability, and launch date for the final report.

  • Add the user economics scorer below scoreMoatFragility:
export function scoreUserEconomics(jd: JobDescription, ctx: CompanyContext): DimensionScore {
  // Flag one-shot usage patterns that cap lifetime value
  const retention = ctx.userMetrics.retention;
  const isOneShot = retention.includes("one-shot") || retention.includes("churn on success");
  const score = isOneShot ? 9 : 4;

  return {
    dimension: "User Economics",
    score,
    verdict: `The product's success kills its own retention. Users build portfolios to get jobs, then leave. There is no compounding value (unlike LinkedIn's network or GitHub's contribution graph). LTV is capped at 3-6 months. Growth requires permanent top-of-funnel acquisition to replace churning successes.`,
    evidence: [
      `Product model: ${ctx.productModel}`,
      `User retention pattern: ${retention}`,
      `No network effects identified`,
      `No compounding user value (portfolio is static after job acquired)`,
      `Comparison: LinkedIn (network grows), GitHub (contributions accumulate), NextWork (one-shot)`,
    ],
  };
}

What does user economics measure?

It checks whether the product's retention model is one-shot (users leave after achieving their goal) or compounding (users get more value over time). A one-shot model means the company must constantly acquire new users to replace successful ones who churn out.

  • Save src/scoring.ts and verify it still parses cleanly:
npx tsx src/scoring.ts

Clean exit, no errors. One more scoring function to go.

  • Add the final scorer, hiring friction, below scoreUserEconomics:
export function scoreHiringFriction(jd: JobDescription, ctx: CompanyContext): DimensionScore {
  // Score how unrealistic the requirements are for what the stage can offer
  const requirementSpecificity = jd.candidateProfile.length + jd.bonusSignals.length;
  const stageAppropriate = ctx.teamSize < 50 && ctx.lastRound.amount.includes("4.45M");
  const score = Math.min(10, requirementSpecificity - (stageAppropriate ? 0 : 3));

  return {
    dimension: "Hiring Friction",
    score: Math.max(1, Math.min(10, score)),
    verdict: `The JD describes ${jd.candidateProfile.length} "strong candidate signals" including first-engineer-at-startup experience, consistent title progression, and domain ownership. This profile has 3+ competing offers at any time. The role stays posted because the pitch (equity at pre-revenue + ambiguity + seed-stage comp) fails the smell test for anyone who matches the described profile.`,
    evidence: [
      `Candidate signals required: ${jd.candidateProfile.length}`,
      `Bonus signals: ${jd.bonusSignals.length}`,
      `Role posted duration: ${ctx.rolePostedDuration}`,
      `Company stage vs. candidate expectation mismatch`,
      `NZ-to-Austin relocation signals burn rate acceleration`,
    ],
  };
}

What does hiring friction score?

It counts how many specific candidate signals and bonus requirements the JD lists, then checks whether the company stage can realistically attract someone who matches all of them. The more signals demanded at a seed stage, the higher the friction score.

  • Save src/scoring.ts and confirm all five functions parse without errors:
npx tsx src/scoring.ts

Clean exit again. All five scoring functions are defined and type-safe. Now they need an orchestrator.

Seeing a syntax error on one of the template literals?

Template literals (the backtick strings with ${...} expressions) are sensitive to unescaped backticks inside them. Check that every opening backtick has a matching close, and that you haven't accidentally used a regular quote where a backtick should be.

help me fix a template literal syntax error in my TypeScript scoring functions

Create the orchestration engine

The engine imports all five scorers, runs them against the inputs, computes an aggregate Deficiency Index, and generates a summary declaration. This is the single function the analyzer calls.

  • Create a new file src/engine.ts and add the imports and main analysis function:
import { JobDescription, CompanyContext, AnalysisResult } from "./types.js";
import {
  scoreCandidateMarketMismatch,
  scoreRevenueMaturity,
  scoreMoatFragility,
  scoreUserEconomics,
  scoreHiringFriction,
} from "./scoring.js";

export function analyzeRole(jd: JobDescription, ctx: CompanyContext): AnalysisResult {
  // Run all five dimension scorers
  const dimensions = [
    scoreCandidateMarketMismatch(jd, ctx),
    scoreRevenueMaturity(jd, ctx),
    scoreMoatFragility(jd, ctx),
    scoreUserEconomics(jd, ctx),
    scoreHiringFriction(jd, ctx),
  ];

  // Compute the aggregate index (unweighted average)
  const deficiencyIndex = Math.round(
    dimensions.reduce((sum, d) => sum + d.score, 0) / dimensions.length * 10
  ) / 10;

  const declaration = generateDeclaration(jd, ctx, dimensions, deficiencyIndex);

  return { jd, context: ctx, dimensions, deficiencyIndex, declaration };
}
  • Add the declaration generator function below analyzeRole in the same file:
function generateDeclaration(
  jd: JobDescription,
  ctx: CompanyContext,
  dimensions: { dimension: string; score: number; verdict: string }[],
  index: number
): string {
  // Synthesize a top-line summary from the worst-scoring dimension
  const worst = dimensions.reduce((a, b) => (a.score > b.score ? a : b));
  return (
    `The ${jd.title} role at ${jd.company} carries a Deficiency Index of ${index}/10. ` +
    `The most critical structural flaw is ${worst.dimension} (${worst.score}/10). ` +
    `The role describes a candidate profile that commands $350-500K TC in today's market, ` +
    `offered by a pre-revenue company with ${ctx.fundingTotal} in total funding, ` +
    `competing against LinkedIn's Verified Skills program for the same "skills verification" positioning. ` +
    `Qualified candidates perform this analysis and walk.`
  );
}

What does the engine do?

  • analyzeRole runs all five scorers and collects their results into an array.
  • deficiencyIndex is the unweighted average of all five scores, rounded to one decimal.
  • generateDeclaration finds the worst-scoring dimension and synthesizes a top-line summary statement for the report.
  • Save src/engine.ts and verify it loads without errors:
npx tsx src/engine.ts

Clean exit. The engine imports scoring.ts successfully, which means all the type relationships are correct.

Now wire the engine into your analyzer so it actually runs the scoring and prints results.

  • Replace the entire contents of src/analyze.ts with the following updated version:
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { JobDescription, CompanyContext } from "./types.js";
import { analyzeRole } from "./engine.js";

const jdPath = resolve("data/nextwork-jd.json");
const ctxPath = resolve("data/nextwork-context.json");

if (!existsSync(jdPath) || !existsSync(ctxPath)) {
  console.error("Missing input files.");
  process.exit(1);
}

const jd: JobDescription = JSON.parse(readFileSync(jdPath, "utf-8"));
const ctx: CompanyContext = JSON.parse(readFileSync(ctxPath, "utf-8"));

console.log(`\nAnalyzing: ${jd.title} at ${jd.company}\n`);

const result = analyzeRole(jd, ctx);

console.log(`Deficiency Index: ${result.deficiencyIndex}/10\n`);
for (const dim of result.dimensions) {
  const bar = "X".repeat(dim.score) + "-".repeat(10 - dim.score);
  console.log(`  [${bar}] ${dim.dimension}: ${dim.score}/10`);
  console.log(`  Verdict: ${dim.verdict}\n`);
}

What changed in the analyzer?

  • Added the analyzeRole import from the engine module.
  • Replaced the simple print statements with a call to analyzeRole(jd, ctx) that returns the full analysis result.
  • The loop prints each dimension as a visual score bar (X for filled, - for empty) plus the verdict text.
  • Save src/analyze.ts.

Before you run this, what do you expect to see? Five dimension names with scores from 1-10, or something else? Think about which dimensions would score highest given the data you seeded.

  • Run the full analysis by executing:
npx tsx src/analyze.ts

You should see output showing all five dimension scores with visual bars and verdicts. The Deficiency Index should be a single number averaging around 8-9 out of 10, with Moat Fragility and User Economics scoring the highest.

Seeing "Cannot find module" errors?

Make sure all import paths use the .js extension: ./types.js, ./scoring.js, ./engine.js. Also confirm your package.json has "type": "module" set.

help me debug module resolution errors in my TypeScript project

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files: src/scoring.ts, src/engine.ts, and src/analyze.ts.

ⓧ I'd like to double check the full code

Here are the three files you built in this step. Scroll up and confirm each section is present in order.

src/scoring.ts should contain, in order: the import from ./types.js, then five exported functions: scoreCandidateMarketMismatch, scoreRevenueMaturity, scoreMoatFragility, scoreUserEconomics, scoreHiringFriction.

src/engine.ts should contain: imports from types and scoring, the exported analyzeRole function, and the private generateDeclaration function.

src/analyze.ts should import analyzeRole from ./engine.js, call it with jd and ctx, and loop over result.dimensions to print score bars and verdicts.

Your tool now converts raw company data into scored, evidence-backed verdicts. Next up, you'll turn these terminal scores into a formatted markdown report that someone could hand to a hiring manager.

Generate the Declaration Report

Your scoring engine works. It takes structured data and produces five scored verdicts backed by evidence.

Scores in a terminal are useful for debugging, but they are not a deliverable. The output needs to be a formatted document you could hand to someone and say "here's why candidates walk away." In this step, you will generate a structured markdown report that makes the analysis tangible and shareable.

In this step, get ready to:
  • Build a markdown report generator that formats scores, verdicts, and evidence into a readable document.
  • Wire the full pipeline so running one command produces a report file on disk.
  • Run the analyzer and find the complete deficiency report saved as a markdown file.
Create the report generator

The report generator takes an AnalysisResult and returns a formatted markdown string. It needs a header with metadata, the synthesized declaration, and each dimension as its own section with a visual score bar.

  • Create a new file src/report.ts and add the import, score visualization helper, and the first half of the report generator:
import { AnalysisResult, DimensionScore } from "./types.js";

export function generateReport(result: AnalysisResult): string {
  const lines: string[] = [];

  // Report header with metadata
  lines.push(`# Role Deficiency Report`);
  lines.push(``);
  lines.push(`**Subject:** ${result.jd.title} at ${result.jd.company}`);
  lines.push(`**Date:** ${new Date().toISOString().split("T")[0]}`);
  lines.push(`**Deficiency Index:** ${result.deficiencyIndex}/10`);
  lines.push(``);
  lines.push(`---`);
  lines.push(``);

  // Declaration section with the synthesized conclusion
  lines.push(`## Declaration`);
  lines.push(``);
  lines.push(result.declaration);
  lines.push(``);
  lines.push(`---`);
  lines.push(``);

What does this code do?

The function builds a markdown string line by line. The header prints the role title, today's date, and the overall deficiency index. The Declaration section uses result.declaration which your engine already generates as a 3-4 sentence synthesis.

The function is not complete yet. You will add the dimension sections and closing logic next.

  • Directly below the code you just added (still inside the generateReport function), add the dimension loop, methodology section, and the score bar helper:
  // Each dimension gets its own section with score, verdict, evidence
  for (const dim of result.dimensions) {
    lines.push(`## ${dim.dimension}`);
    lines.push(``);
    lines.push(`**Score:** ${renderScoreBar(dim.score)}`);
    lines.push(``);
    lines.push(`**Verdict:** ${dim.verdict}`);
    lines.push(``);
    lines.push(`**Evidence:**`);
    for (const e of dim.evidence) {
      lines.push(`- ${e}`);
    }
    lines.push(``);
    lines.push(`---`);
    lines.push(``);
  }

  // Methodology section explaining the scoring approach
  lines.push(`## Methodology`);
  lines.push(``);
  lines.push(`Each dimension is scored 1-10 where 10 represents maximum deficiency.`);
  lines.push(`Scores are derived from public evidence: press releases, terms of service,`);
  lines.push(`npm package data, LinkedIn product announcements, and job posting analysis.`);
  lines.push(`The Deficiency Index is the unweighted average across all five dimensions.`);

  return lines.join("\n");
}

function renderScoreBar(score: number): string {
  const filled = "X".repeat(score);
  const empty = "-".repeat(10 - score);
  return `[${filled}${empty}] ${score}/10`;
}

What does this code do?

The for loop iterates over all five dimension scores. Each one gets a markdown section with a visual score bar (like [XXXXXXXX--] 8/10), its verdict, and bullet-pointed evidence.

The renderScoreBar helper creates the visual bar using filled X characters and empty dashes. The Methodology section at the end explains how scores were derived so the report stands on its own.

  • Save src/report.ts.
  • Confirm the file is syntactically valid by running:
npx tsx src/report.ts

You should see no output and no errors. The file exports functions but has no top-level side effects, so a clean exit means the syntax is correct.

Seeing a syntax error?

Check that your generateReport function has a matching closing brace } after the return lines.join("\n") line. The renderScoreBar function should sit outside and below generateReport.

help me debug a syntax error in my report.ts file

Wire the analyzer to produce the report file

The report generator is ready. Now you need to connect it to the entry point so that running the analyzer reads the inputs, scores them, generates the report, and writes it to disk.

  • Open src/analyze.ts and replace the entire file contents with the following imports and input-parsing section:
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { JobDescription, CompanyContext } from "./types.js";
import { analyzeRole } from "./engine.js";
import { generateReport } from "./report.js";

function main(): void {
  const jdPath = resolve("data/nextwork-jd.json");
  const ctxPath = resolve("data/nextwork-context.json");

  // Validate input files exist before proceeding
  if (!existsSync(jdPath) || !existsSync(ctxPath)) {
    console.error("Missing input files. Ensure data/nextwork-jd.json and data/nextwork-context.json exist.");
    process.exit(1);
  }

  const jd: JobDescription = JSON.parse(readFileSync(jdPath, "utf-8"));
  const ctx: CompanyContext = JSON.parse(readFileSync(ctxPath, "utf-8"));

What changed from the previous version?

The imports now include writeFileSync and mkdirSync for writing the report to disk. You also import generateReport from the file you just created. The code is now wrapped in a main() function for cleaner structure.

  • Directly below the code you just pasted (still inside main), add the analysis execution, terminal output, and file-writing logic:
  console.log(`\nAnalyzing: ${jd.title} at ${jd.company}\n`);

  // Run the scoring engine
  const result = analyzeRole(jd, ctx);

  // Print condensed version to terminal
  console.log(`Deficiency Index: ${result.deficiencyIndex}/10\n`);
  for (const dim of result.dimensions) {
    const bar = "X".repeat(dim.score) + "-".repeat(10 - dim.score);
    console.log(`  [${bar}] ${dim.dimension}: ${dim.score}/10`);
  }
  console.log(`\nDeclaration:\n${result.declaration}\n`);

  // Generate and write the full markdown report
  const report = generateReport(result);
  const reportsDir = resolve("reports");
  if (!existsSync(reportsDir)) mkdirSync(reportsDir);
  const outPath = resolve(reportsDir, "nextwork-deficiency.md");
  writeFileSync(outPath, report, "utf-8");
  console.log(`Full report written to: ${outPath}`);
}

main();

What does this code do?

The terminal output is now condensed: just the score bars and the declaration. The verbose per-dimension verdicts are no longer printed because they live in the full report instead.

The file-writing block creates a reports/ directory if it does not exist, generates the markdown, and writes it to reports/nextwork-deficiency.md. The last log line confirms the path so you know exactly where to find it.

  • Save src/analyze.ts.
Run the tool and verify the report

The full pipeline is wired. Before you run it, think about what you expect to happen.

  • Before running the command below, predict: will the tool print output AND write a file, or just one of the two?
  • Run the analyzer by executing:
npx tsx src/analyze.ts

You should see condensed score bars for all five dimensions, the declaration paragraph, and a final line confirming the report was written to disk.

  • Confirm the report file exists by running:
cat reports/nextwork-deficiency.md

You should see the full formatted markdown report: a title, today's date, the Deficiency Index, the Declaration section, and then all five dimensions each with their score bar, verdict, and evidence bullets.

Seeing "Cannot find module" for report.js?

Make sure your import in src/analyze.ts uses the .js extension: import { generateReport } from "./report.js". TypeScript with ESM requires the .js extension in import paths even though the source file is .ts.

help me fix a module import error in my TypeScript project

✔️ Awesome, I've got everything!

Great. Confirm you have saved both src/report.ts and src/analyze.ts, and that reports/nextwork-deficiency.md exists on disk.

ⓧ I'd like to double check the full code

Scroll up and confirm each code chunk is present in order. Your src/report.ts should have the import, then generateReport (header/declaration logic, then dimension loop and methodology), and finally renderScoreBar. Your src/analyze.ts should have five imports, a main() function that reads files, runs the engine, prints output, generates the report, writes it to reports/, and calls main() at the bottom.

You now have a CLI tool that produces a real artifact. The analysis is no longer locked in your terminal. Next up, you will extend it with steelman counter-arguments that turn this one-sided critique into a balanced decision framework.

Secret mission

Add the Steelman Counter-Arguments

A one-sided teardown is easy to dismiss. A balanced analysis that acknowledges the bull case for each flaw, then explains why it doesn't resolve the deficiency, is much harder to argue with. Turn your report from a critique into a decision framework.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files or delete them.

Resources you used:

  • The role-signal project folder on your Desktop (source code, data files, generated reports, and node_modules).

Keep everything running

No action needed. Choose this if you want to run the analyzer again or swap in a different job description.

  • Your role-signal folder stays on your Desktop with all source code, data files, and the generated report.
  • Run npx tsx src/analyze.ts any time to regenerate the report.

Pause - I'll come back to this later

Free up disk space from dependencies while keeping your source code intact.

  • Delete the node_modules folder inside role-signal to reclaim space. Run this from inside the project folder:

macOS

rm -rf node_modules

Windows

rmdir /s /q node_modules
  • When you come back, run npm install from the role-signal folder to restore dependencies.

Delete - I don't want to use this again

Remove all project files from your machine.

  • Delete the entire role-signal folder by running this from your Desktop:

macOS

cd ~/Desktop
rm -rf role-signal

Windows

cd ~/Desktop
rmdir /s /q role-signal
  • This removes all source code, data files, the generated report, and installed dependencies.

That's a Wrap!

That's a Wrap!

Nice work. You just built a deterministic analysis engine that structurally exposes why a job role fails to attract the candidate it describes.

You've learned how to:

  • Build a TypeScript CLI that ingests structured JSON inputs and produces scored analysis without relying on an external LLM.
  • Design a five-dimension scoring framework (candidate mismatch, revenue maturity, moat fragility, user economics, hiring friction) where every score is backed by cited evidence and a plain-English verdict.
  • Generate a formatted markdown declaration report you can hand to someone and say "here's why qualified candidates walk away from this role."
  • Secret Mission: Added steelman counter-arguments for each dimension and a "When This Role Makes Sense" section, turning a one-sided teardown into a balanced decision framework.

Ready to quiz yourself?