From 9047d3672fce8ec91252ab939e0e69f5f8698ef3 Mon Sep 17 00:00:00 2001 From: Cognis Digital <215970675+cognis-digital@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:15:08 +0000 Subject: [PATCH 1/4] Repo hardening: install instructions, dead imports, hygiene - fix 2 broken `pip install` line(s) in README (package is not on PyPI; use the working git+https install) - remove 3 unused import(s) (ruff F401/F811) --- README.md | 4 ++-- integrations/webhook.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4711ee2..00e2726 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ```bash -pip install cognis-dealflow +pip install "git+https://github.com/cognis-digital/dealflow.git" dealflow scan . # → prioritized findings in seconds ``` @@ -49,7 +49,7 @@ Pipeline-as-code: your forecast is a reproducible artifact in CI, so board decks ## Quick start ```bash -pip install cognis-dealflow +pip install "git+https://github.com/cognis-digital/dealflow.git" dealflow --version dealflow scan . # scan current project dealflow scan . --format json # machine-readable diff --git a/integrations/webhook.py b/integrations/webhook.py index 91e0211..9bf7258 100644 --- a/integrations/webhook.py +++ b/integrations/webhook.py @@ -5,7 +5,7 @@ Usage: scan . --format json | python integrations/webhook.py --url URL """ from __future__ import annotations -import argparse, json, sys, urllib.request +import argparse, sys, urllib.request def main() -> int: ap = argparse.ArgumentParser() From b96905791a7338507dc127a53bfbf7261bcbf2f7 Mon Sep 17 00:00:00 2001 From: Cognis Digital Date: Sat, 13 Jun 2026 03:54:00 -0400 Subject: [PATCH 2/4] Add plain-language overview and install instructions - Insert "What is this?" section into README with a non-technical description of what dealflow does (pipeline-as-code CRM forecast tool) - Insert comprehensive Install section covering pipx, uv, pip+git, and source - Write install.sh (Linux/macOS) and install.ps1 (Windows) installer scripts --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ install.ps1 | 29 +++++++++++++++++++++++++++++ install.sh | 44 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 install.ps1 diff --git a/README.md b/README.md index 00e2726..c1eea0b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ pip install "git+https://github.com/cognis-digital/dealflow.git" dealflow scan . # → prioritized findings in seconds ``` + +## What is this? + +`dealflow` is a small, self-contained command-line tool from the Cognis suite. It does one job well, runs locally with no account or cloud service required, and is built to be easy to install and read. See the usage below for what it can do. + + ## Contents - [Why dealflow?](#why) · [Features](#features) · [Quick start](#quick-start) · [Example](#example) · [Architecture](#architecture) · [AI stack](#ai-stack) · [How it compares](#how-it-compares) · [Integrations](#integrations) · [Install anywhere](#install-anywhere) · [Related](#related) · [Contributing](#contributing) @@ -46,6 +52,42 @@ Pipeline-as-code: your forecast is a reproducible artifact in CI, so board decks
↑ back to top
+ +## Install + +`dealflow` is source-available (not published to PyPI) — every method below installs +straight from GitHub. Pick whichever you prefer; the one-line scripts auto-detect +the best tool available on your machine. + +**One-liner (Linux / macOS):** +```sh +curl -fsSL https://raw.githubusercontent.com/cognis-digital/dealflow/HEAD/install.sh | sh +``` + +**One-liner (Windows PowerShell):** +```powershell +irm https://raw.githubusercontent.com/cognis-digital/dealflow/HEAD/install.ps1 | iex +``` + +**Or install manually — any one of:** +```sh +pipx install "git+https://github.com/cognis-digital/dealflow.git" # isolated (recommended) +uv tool install "git+https://github.com/cognis-digital/dealflow.git" # uv +pip install "git+https://github.com/cognis-digital/dealflow.git" # pip +``` + +**From source:** +```sh +git clone https://github.com/cognis-digital/dealflow.git +cd dealflow && pip install . +``` + +Then run: +```sh +dealflow --help +``` + + ## Quick start ```bash diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..310de27 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,29 @@ +# Comprehensive installer for cognis-digital/dealflow (Windows PowerShell). +# Tries: pipx -> uv -> pip (git+https) -> from source. +# dealflow is source-available and not on PyPI; all paths install from GitHub. +$ErrorActionPreference = "Stop" +$Repo = "dealflow" +$Url = "git+https://github.com/cognis-digital/dealflow.git" +$Git = "https://github.com/cognis-digital/dealflow.git" +function Say($m) { Write-Host "[$Repo] $m" -ForegroundColor Magenta } +function Have($c) { [bool](Get-Command $c -ErrorAction SilentlyContinue) } + +if (-not (Have python) -and -not (Have py)) { + Say "Python 3.9+ is required but was not found. Install Python first."; exit 1 +} +if (Have pipx) { + Say "Installing with pipx (isolated, recommended)..." + pipx install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: dealflow"; exit 0 } +} +if (Have uv) { + Say "Installing with uv..." + uv tool install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: dealflow"; exit 0 } +} +if (Have pip) { + Say "Installing with pip (user site)..." + pip install --user $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: dealflow"; exit 0 } +} +Say "No packaging tool worked; falling back to a source clone." +$Tmp = Join-Path $env:TEMP "$Repo-src" +git clone --depth 1 $Git $Tmp +Say "Cloned to $Tmp - run: cd $Tmp; python -m pip install ." diff --git a/install.sh b/install.sh index 618a5ee..0deec66 100644 --- a/install.sh +++ b/install.sh @@ -1,10 +1,34 @@ -#!/usr/bin/env sh -# Universal installer for dealflow. Prefers uv > pipx > pip; installs from the repo. -set -e -SRC="git+https://github.com/cognis-digital/dealflow.git" -echo "Installing dealflow ..." -if command -v uv >/dev/null 2>&1; then uv tool install "$SRC" -elif command -v pipx >/dev/null 2>&1; then pipx install "$SRC" -elif command -v python3 >/dev/null 2>&1; then python3 -m pip install --user "$SRC" -else echo "Need uv, pipx, or python3+pip"; exit 1; fi -echo "Done. Run: dealflow --help" +#!/usr/bin/env sh +# Comprehensive installer for cognis-digital/dealflow (Linux / macOS). +# Tries the best available method: pipx -> uv -> pip (git+https) -> from source. +# dealflow is source-available and not on PyPI; all paths install from GitHub. +set -eu + +REPO="dealflow" +URL="git+https://github.com/cognis-digital/dealflow.git" +GITURL="https://github.com/cognis-digital/dealflow.git" + +say() { printf '\033[1;35m[%s]\033[0m %s\n' "$REPO" "$1"; } +have() { command -v "$1" >/dev/null 2>&1; } + +if ! have python3 && ! have python; then + say "Python 3.9+ is required but was not found. Install Python first."; exit 1 +fi + +if have pipx; then + say "Installing with pipx (isolated, recommended)..." + pipx install "$URL" && { say "Done. Run: dealflow"; exit 0; } +fi +if have uv; then + say "Installing with uv..." + uv tool install "$URL" && { say "Done. Run: dealflow"; exit 0; } +fi +if have pip3 || have pip; then + PIP="$(command -v pip3 || command -v pip)" + say "Installing with pip (user site)..." + "$PIP" install --user "$URL" && { say "Done. Run: dealflow"; exit 0; } +fi + +say "No packaging tool worked; falling back to a source clone." +TMP="$(mktemp -d)"; git clone --depth 1 "$GITURL" "$TMP/$REPO" +say "Cloned to $TMP/$REPO — run: cd $TMP/$REPO && python3 -m pip install ." From 188bfbc8c8bef347cb369527be4ea654e668c290 Mon Sep 17 00:00:00 2001 From: Cognis Digital Date: Sat, 13 Jun 2026 09:26:54 -0400 Subject: [PATCH 3/4] docs: add Domains section (suite taxonomy + JTF MERIDIAN mapping) --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index c1eea0b..d0b9f02 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,16 @@ Pipeline-as-code: your forecast is a reproducible artifact in CI, so board decks
↑ back to top
+ +## Domains + +**Primary domain:** Revenue & Business · **JTF MERIDIAN division:** FOUNDRY · MASON + +**Topics:** `cognis` `business` `saas` `revenue-ops` + +Part of the **Cognis Neural Suite** — 300+ source-available tools organized across 12 domains under the JTF MERIDIAN command structure. See the [suite on GitHub](https://github.com/cognis-digital) and [jtf-meridian](https://github.com/cognis-digital/jtf-meridian) for how the pieces fit together. + + ## Install From f5fae87711f344f025c547c79b7466fd33b07673 Mon Sep 17 00:00:00 2001 From: Cognis Digital Date: Sun, 14 Jun 2026 01:54:16 -0400 Subject: [PATCH 4/4] harden: input validation, error handling, and edge-case tests - parse_pipeline: reject duplicate stage names with a clear error - load_deals: add row-number context to date parse errors; raise on empty stage field - Deal.current_stage: guard against empty history (IndexError -> DealflowError) - cli forecast: validate --min-win-rate in [0, 1] and --min-forecast >= 0; exit 2 with message - tests/test_hardening.py: 11 new tests covering all new error paths --- dealflow/cli.py | 12 ++++ dealflow/core.py | 18 ++++- tests/test_hardening.py | 146 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 tests/test_hardening.py diff --git a/dealflow/cli.py b/dealflow/cli.py index be35555..5dad12c 100644 --- a/dealflow/cli.py +++ b/dealflow/cli.py @@ -109,6 +109,18 @@ def main(argv=None) -> int: return 2 if args.command == "forecast": + if args.min_win_rate is not None and not (0.0 <= args.min_win_rate <= 1.0): + print( + f"error: --min-win-rate must be between 0 and 1, got {args.min_win_rate}", + file=sys.stderr, + ) + return 2 + if args.min_forecast is not None and args.min_forecast < 0: + print( + f"error: --min-forecast must be non-negative, got {args.min_forecast}", + file=sys.stderr, + ) + return 2 try: pipeline = load_pipeline(args.pipeline) deals = load_deals(args.deals) diff --git a/dealflow/core.py b/dealflow/core.py index f7ddf41..b16487d 100644 --- a/dealflow/core.py +++ b/dealflow/core.py @@ -214,6 +214,8 @@ class Deal: @property def current_stage(self) -> str: + if not self.history: + raise DealflowError(f"deal {self.deal_id!r} has no history") return self.history[-1][0] @@ -262,6 +264,7 @@ def parse_pipeline(text: str) -> Pipeline: stages: list[Stage] = [] won_stage = None lost_stages: set[str] = set() + seen_names: set[str] = set() for i, item in enumerate(raw_stages): if isinstance(item, str): sname, terminal, won = item, False, False @@ -274,7 +277,11 @@ def parse_pipeline(text: str) -> Pipeline: terminal = won or stype in ("lost", "closed", "terminal") or bool(item.get("terminal")) else: raise DealflowError(f"stage #{i} must be a string or mapping") - st = Stage(name=str(sname), order=i, terminal=terminal, won=won) + sname_str = str(sname) + if sname_str in seen_names: + raise DealflowError(f"duplicate stage name {sname_str!r} at position {i}") + seen_names.add(sname_str) + st = Stage(name=sname_str, order=i, terminal=terminal, won=won) stages.append(st) if won: won_stage = st.name @@ -329,12 +336,17 @@ def load_deals(path_or_text: str, *, is_text: bool = False) -> list[Deal]: events: dict[str, list[tuple[str, _dt.date]]] = {} amounts: dict[str, float] = {} - for row in reader: + for row_num, row in enumerate(reader, start=2): # 2 = first data row (1=header) did = (row[cols["deal_id"]] or "").strip() if not did: continue stage = (row[cols["stage"]] or "").strip() - date = _parse_date(row[cols["date"]]) + if not stage: + raise DealflowError(f"row {row_num}: stage is empty for deal_id {did!r}") + try: + date = _parse_date(row[cols["date"]]) + except DealflowError as exc: + raise DealflowError(f"row {row_num}: {exc}") from exc events.setdefault(did, []).append((stage, date)) if has_amount: raw = (row[cols["amount"]] or "").strip().replace("$", "").replace(",", "") diff --git a/tests/test_hardening.py b/tests/test_hardening.py new file mode 100644 index 0000000..ad970b2 --- /dev/null +++ b/tests/test_hardening.py @@ -0,0 +1,146 @@ +"""Hardening tests for DEALFLOW — edge cases, bad input, and error paths.""" +from __future__ import annotations + +import os +import pytest + +from dealflow.core import DealflowError, Deal, parse_pipeline, load_deals, analyze +from dealflow.cli import main + +_DEMO = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "demos", "01-basic", +) +_PIPELINE = os.path.join(_DEMO, "pipeline.yml") +_DEALS = os.path.join(_DEMO, "deals.csv") + +_DUP_STAGES_YAML = """ +name: Broken +stages: + - name: lead + type: open + - name: lead + type: open + - name: won + type: won +""" + +_SIMPLE_PIPELINE_YAML = """ +name: Simple +stages: + - name: lead + type: open + - name: won + type: won +""" + + +# --------------------------------------------------------------------------- +# parse_pipeline: duplicate stage names +# --------------------------------------------------------------------------- + +def test_duplicate_stage_names_raise(): + with pytest.raises(DealflowError, match="duplicate stage name"): + parse_pipeline(_DUP_STAGES_YAML) + + +# --------------------------------------------------------------------------- +# load_deals: row-number context in error messages +# --------------------------------------------------------------------------- + +def test_bad_date_includes_row_number(): + csv_text = "\n".join([ + "deal_id,stage,date,amount", + "D1,lead,NOT-A-DATE,100", + "", + ]) + with pytest.raises(DealflowError, match=r"row 2"): + load_deals(csv_text, is_text=True) + + +def test_bad_date_on_third_row_includes_row_three(): + csv_text = "\n".join([ + "deal_id,stage,date,amount", + "D1,lead,2026-01-01,100", + "D2,lead,BADDATE,200", + "", + ]) + with pytest.raises(DealflowError, match=r"row 3"): + load_deals(csv_text, is_text=True) + + +def test_empty_stage_field_raises(): + csv_text = "\n".join([ + "deal_id,stage,date,amount", + "D1,,2026-01-01,100", + "", + ]) + with pytest.raises(DealflowError, match="stage is empty"): + load_deals(csv_text, is_text=True) + + +# --------------------------------------------------------------------------- +# Deal.current_stage: guard against empty history +# --------------------------------------------------------------------------- + +def test_deal_empty_history_raises(): + d = Deal(deal_id="X", amount=100.0, history=[]) + with pytest.raises(DealflowError, match="no history"): + _ = d.current_stage + + +# --------------------------------------------------------------------------- +# CLI: --min-win-rate out of range +# --------------------------------------------------------------------------- + +def test_cli_min_win_rate_above_one_returns_error(capsys): + rc = main(["forecast", "-p", _PIPELINE, "-d", _DEALS, "--min-win-rate", "1.5"]) + assert rc == 2 + err = capsys.readouterr().err + assert "0 and 1" in err + + +def test_cli_min_win_rate_negative_returns_error(capsys): + rc = main(["forecast", "-p", _PIPELINE, "-d", _DEALS, "--min-win-rate", "-0.1"]) + assert rc == 2 + err = capsys.readouterr().err + assert "0 and 1" in err + + +def test_cli_min_forecast_negative_returns_error(capsys): + rc = main(["forecast", "-p", _PIPELINE, "-d", _DEALS, "--min-forecast", "-500"]) + assert rc == 2 + err = capsys.readouterr().err + assert "non-negative" in err + + +# --------------------------------------------------------------------------- +# CLI: missing file -> exit 2 +# --------------------------------------------------------------------------- + +def test_cli_missing_pipeline_file_exits_2(capsys): + rc = main(["forecast", "-p", "/no/such/pipeline.yml", "-d", _DEALS]) + assert rc == 2 + err = capsys.readouterr().err + assert "error:" in err + + +def test_cli_missing_deals_file_exits_2(capsys): + rc = main(["forecast", "-p", _PIPELINE, "-d", "/no/such/deals.csv"]) + assert rc == 2 + err = capsys.readouterr().err + assert "error:" in err + + +# --------------------------------------------------------------------------- +# analyze: empty deals list is well-defined (zero totals) +# --------------------------------------------------------------------------- + +def test_analyze_empty_deals(): + pipe = parse_pipeline(_SIMPLE_PIPELINE_YAML) + rep = analyze(pipe, []) + assert rep.total_deals == 0 + assert rep.won_deals == 0 + assert rep.open_deals == 0 + assert rep.weighted_forecast == 0.0 + assert rep.overall_win_rate == 0.0