text:
Fuzzy-Anki
==========
A web-browser-based browser for [Anki](http://ankisrs.net/) decks and review logs.

**What is this?**
This does two things: (1) view decks, and (2) analyze review logs, both in the context of Anki, the spaced-repetition flashcard application.

**Browse deck contents?**
The really valuable thing about Anki is the culture of [deck-sharing](https://ankiweb.net/shared/decks/) grown around it. Currently, the standard way to check out the contents of a deck seems to be to open it in the Anki computer-based application's built-in deck browser, which presents you with a microscope-level view of the contents.

In contrast, this deck browser runs in your web browser. When you visit http://fasiha.github.io/fuzzy-anki/, your browser downloads the entire JavaScript application. You then either point Fuzzy-Anki to an Anki deck available online or "upload" one from your local disk. (Upload is in "quotes" here because the deck isn't being sent to any server on the internet.)

Don't yet have an Anki deck of interest? I'm convinced you will be able to [find](https://ankiweb.net/shared/decks/) several.

**Analyze review logs?**
Anki stores your review performance in a SQLite database. As you can imagine, after months and years, this database becomes very valuable (and we're all glad Anki does cloud-based backup and sync well). While some are happy with Anki's built-in tools for analyzing and visualizing this performance data, it is always nice to have data at your fingertips, ready for your own investigations.

Upload your Anki collections database (usually found in `Documents/Anki/<your name>/collection.anki2`) to convert it to a CSV spreadsheet, ready to import into Excel-like applications or your data analysis software of choice. (Since I prefer Javascript, the data will be available in a global variable, `revlogTable`.)

You can also select decks and view visualizations of your progress. Currently, there are four interactive graphs generated. [See the Wiki for the juicy, colorful details](https://github.com/fasiha/fuzzy-anki/wiki).

**Why, specifically?**
Nayr posted a [community service announcement](http://forum.koohii.com/viewtopic.php?pid=223330) about a shared Anki deck he made called [Nayr's Japanese Core5000](https://ankiweb.net/shared/info/1269450669), based on _A Frequency Dictionary of Japanese_ by Yukio Tono, Makoto Yamazaki, and Kikuo Maekawa (2013, names in the Western style), which seemed to be getting some positive buzz on the Koohii forum. Ankiweb showed three facts of the thousand(s) on [its shared page](https://ankiweb.net/shared/info/1269450669) which did indeed seem interesting, but I wanted to view the other entries in a more wholesale and wholesome way than Anki's deck browser or a spreadsheet program.

For a while, if you uploaded that initial deck, Fuzzy-Anki would recognize it and perform some data processing to enhance its display (see my [Koohii forum post](http://forum.koohii.com/viewtopic.php?pid=223323#p223323) for the sordid details). Now that processing is disabled since those edits have been rolled back into the original deck. The world turns, things come to pass.

As for review logs, Koohii forum-goer yogert909 brought into sharp focus the need for Anki review export and analysis [in this post](http://forum.koohii.com/viewtopic.php?pid=222784#p222784).

**Can I browse any deck that I have on my computer?**
Yes. You can have Anki export one of your decks as an APKG file, which you can upload to Fuzzy-Anki. Since this is entirely a client-side application, you're not uploading your deck to anyone's server, it's all contained on your device.

On the subject of privacy, if you provide Fuzzy-Anki with a URL of an online deck, then whoever is hosting that deck will have a log of your browser downloading it.

**What is Anki?**
It's a major player in the spaced repetition software space. Its defining features may include: written in Python, uses PyQT for GUI and SQLite for databasing, a large user-base and active content and plug-in creation community, cloud backup and sync, and cloud quizzing (but as of now no deck importing).

And for introductions to SRS, see paeans by [Josh Foer](http://www.theguardian.com/education/2012/nov/09/learn-language-in-three-months), [Khatzumoto](http://www.alljapaneseallthetime.com/blog/what-is-an-srs), [Samuel Alexander](http://www.xamuel.com/spaced-repetition-systems/), [Gwern](http://www.gwern.net/Spaced%20repetition), [Sartak](http://sartak.org/2010/01/on-learning.html), [Derek Sivers](http://sivers.org/srs), and a profile of SuperMemo's creator in [Wired](http://archive.wired.com/medtech/health/magazine/16-05/ff_wozniak?currentPage=all).

**Why a client-side JavaScript browser app?**
Browsers have been the best platform for graphical display of information for some years now, at least since the mid-2000s.

Programmers building custom, stand-alone, native, GUI-based data display applications have to work very hard to make something that they can quickly prototype and that their users can heavily customize. In contrast, with a browser-based application, a programmer can change the software on the server (in this case, one JavaScript file hosted on GitHub.io) for all users to see the new version. And then those users can pop open a [JavaScript console](http://jsforcats.com/) to browse the source code, understand the website's elements, and customize it in ways limited only by their programming-fu.

These users can be much more confident that their browsers will secure them against malicious code than they can of stand-alone applications. And they can expect surprisingly smooth, performant applications thanks to the immense resources web-centered entities like Google, the Mozilla Foundation, Apple, Microsoft have poured into making very efficient web browsers and JavaScript engines.

**Acknowledgements** This application uses
- [D3.js](http://d3js.org), by Mike Bostock and friends, for a mind-bending experience while mapping data to browser elements
- [sql.js](https://github.com/kripken/sql.js), by Alon Zakai and friends, for a JavaScript version of SQLite through Emscripten goodness
- [zlib.js](https://github.com/imaya/zlib.js), by Imaya Yuta and friends, for JavaScript-based ZIP decompression
- [XRegExp](http://xregexp.com/), by Steven Levithan and friends, for Unicode-aware regular expression support in JavaScript (regular expressions: they search text)
- [C3.js](http://c3js.org/), by Masayuki Tanaka and friends, a plot-oriented wrapper to D3.js (what LaTeX is to TeX, what C is to assembly)
- [underscore.js](http://underscorejs.org/), by Jeremy Ashkenas and friends, for functional Javascript candy
- [nice-json2csv](https://github.com/matteofigus/nice-json2csv), by Matteo Figus and friend: what the name says (edited for browser and Excel CSV style)
- [JQuery](http://jquery.com/), for an embarrassingly small amount of things
- [CORS-Anywhere](http://cors-anywhere.herokuapp.com), not a software library but an internet service, allowing your browser to download decks not hosted by GitHub.io

Because of these fine building blocks, Fuzzy-Anki took a handful of hours to throw together. Many, many thanks to their makers, and to the other makers before them who inspired them to make these.

Koohii Forum-goers, especially Vempele, have offered much in the way of beta-testing. Thank you!

**Legal** This software is in the public domain. See LICENSE for the Unlicense.


text:
# [3.0.0](https://github.com/peerigon/scriptlint/compare/v2.2.0...v3.0.0) (2023-04-25)

### Bug Fixes

* semantic-release needs node 18 ([#66](https://github.com/peerigon/scriptlint/issues/66)) ([2e2cd80](https://github.com/peerigon/scriptlint/commit/2e2cd804dbbe4f97bdb872ab6ce8bb4aba35f232))

### Features

* dependencies ([#65](https://github.com/peerigon/scriptlint/issues/65)) ([bbf4764](https://github.com/peerigon/scriptlint/commit/bbf4764ed301d6cd96243c5af4ce81e59c561583))

### BREAKING CHANGES

* node version bump

# [2.2.0](https://github.com/peerigon/scriptlint/compare/v2.1.10...v2.2.0) (2022-06-13)

### Features

* **namespaces:** additional "script" namespace ([#59](https://github.com/peerigon/scriptlint/issues/59)) ([d9380c7](https://github.com/peerigon/scriptlint/commit/d9380c789556b07513505cdefba3f745426ef3eb))

## [2.1.10](https://github.com/peerigon/scriptlint/compare/v2.1.9...v2.1.10) (2022-03-28)

### Bug Fixes

* bump minimist from 1.2.5 to 1.2.6 ([#55](https://github.com/peerigon/scriptlint/issues/55)) ([783d374](https://github.com/peerigon/scriptlint/commit/783d37437eaf6fea349fc1c71da2c4898b0b7f30))

## [2.1.9](https://github.com/peerigon/scriptlint/compare/v2.1.8...v2.1.9) (2021-12-20)

### Bug Fixes

* update and pin action dependencies ([#54](https://github.com/peerigon/scriptlint/issues/54)) ([4a85555](https://github.com/peerigon/scriptlint/commit/4a8555537d802c9f9ecfbe0d1416d2226dbba52f))

## [2.1.8](https://github.com/peerigon/scriptlint/compare/v2.1.7...v2.1.8) (2021-09-23)

### Bug Fixes

* dependencies and dev improvements ([a79a095](https://github.com/peerigon/scriptlint/commit/a79a095908658b3a8a4b230c5623ff65a8a1be67))

## [2.1.7](https://github.com/peerigon/scriptlint/compare/v2.1.6...v2.1.7) (2021-08-12)

### Bug Fixes

* **deps:** bump path-parse from 1.0.6 to 1.0.7 ([#50](https://github.com/peerigon/scriptlint/issues/50)) ([dff7d22](https://github.com/peerigon/scriptlint/commit/dff7d2208d3aa96fca578e884f7e1f491872d5b2))

## [2.1.6](https://github.com/peerigon/scriptlint/compare/v2.1.5...v2.1.6) (2021-06-14)

### Bug Fixes

* **deps:** bump normalize-url from 4.5.0 to 4.5.1 ([#49](https://github.com/peerigon/scriptlint/issues/49)) ([ab0ada5](https://github.com/peerigon/scriptlint/commit/ab0ada53b0f01e82b500bbdfb3aa6bbe0b33a9f0))

## [2.1.5](https://github.com/peerigon/scriptlint/compare/v2.1.4...v2.1.5) (2021-05-30)

### Bug Fixes

* **deps:** bump ws from 7.4.4 to 7.4.6 ([#48](https://github.com/peerigon/scriptlint/issues/48)) ([6b9f675](https://github.com/peerigon/scriptlint/commit/6b9f67564f6b9343a7f67a4f87d915f90ea65156))

## [2.1.4](https://github.com/peerigon/scriptlint/compare/v2.1.3...v2.1.4) (2021-05-11)

### Bug Fixes

* **deps:** bump hosted-git-info from 2.8.8 to 2.8.9 ([#46](https://github.com/peerigon/scriptlint/issues/46)) ([f14d229](https://github.com/peerigon/scriptlint/commit/f14d2296b92294e177cd020c3af1fcc0cb444777))

## [2.1.3](https://github.com/peerigon/scriptlint/compare/v2.1.2...v2.1.3) (2021-04-01)

### Bug Fixes

* README ([#43](https://github.com/peerigon/scriptlint/issues/43)) ([33a8119](https://github.com/peerigon/scriptlint/commit/33a8119cd5f007c0e9b7c2f88bafd92ff750b90b))
* update dependencies ([#44](https://github.com/peerigon/scriptlint/issues/44)) ([b76b2db](https://github.com/peerigon/scriptlint/commit/b76b2dbd60028df2a7eb4e31dfe59fe64d3157ff))
* **trigger:** manually trigger release after dependency patch ([0092dce](https://github.com/peerigon/scriptlint/commit/0092dce377d55cd0d65880e2be6057a240621929))

## [2.1.2](https://github.com/peerigon/scriptlint/compare/v2.1.1...v2.1.2) (2020-09-10)

### Bug Fixes

* prettier && eslint --fix ([8f3aa72](https://github.com/peerigon/scriptlint/commit/8f3aa72de8bc84b287d1da4e20a1b6897a5be351))
* run prettier and hopefully release to npm ([1820e06](https://github.com/peerigon/scriptlint/commit/1820e06b7d17c11cda1e364043669105db03a2b0))

## [2.1.1](https://github.com/peerigon/scriptlint/compare/v2.1.0...v2.1.1) (2020-03-25)

### Bug Fixes

* **dependencies:** move from yarn to npm over lack of `audit fix` 🤬 ([160b717](https://github.com/peerigon/scriptlint/commit/160b717))

# [2.1.0](https://github.com/peerigon/scriptlint/compare/v2.0.5...v2.1.0) (2020-03-25)

### Bug Fixes

* **tests:** jest config typo ([b378e5e](https://github.com/peerigon/scriptlint/commit/b378e5e))

### Features

* add lint namespace ([a04b1ce](https://github.com/peerigon/scriptlint/commit/a04b1ce))
* Merge pull request [#34](https://github.com/peerigon/scriptlint/issues/34) from peerigon/lint-namespace ([4eaa726](https://github.com/peerigon/scriptlint/commit/4eaa726))

## [2.0.5](https://github.com/peerigon/scriptlint/compare/v2.0.4...v2.0.5) (2020-03-19)

### Bug Fixes

* **dependencies:** updtr guided updates ([04af746](https://github.com/peerigon/scriptlint/commit/04af746))
* **dependencies:** updtr guided updates ([1c92697](https://github.com/peerigon/scriptlint/commit/1c92697))

## [2.0.4](https://github.com/peerigon/scriptlint/compare/v2.0.3...v2.0.4) (2020-03-05)

### Bug Fixes

* **license:** Add missing license ([0dbb51b](https://github.com/peerigon/scriptlint/commit/0dbb51b))

## [2.0.3](https://github.com/peerigon/scriptlint/compare/v2.0.2...v2.0.3) (2020-02-26)

### Bug Fixes

* **documentation:** Fix typo in README ([bc2d943](https://github.com/peerigon/scriptlint/commit/bc2d943))

## [2.0.2](https://github.com/peerigon/scriptlint/compare/v2.0.1...v2.0.2) (2020-02-26)

### Bug Fixes

* docs and design stuff ([#26](https://github.com/peerigon/scriptlint/issues/26)) ([c0a2bf9](https://github.com/peerigon/scriptlint/commit/c0a2bf9))
* **documentation:** overall documentation rewrite ([f586cc0](https://github.com/peerigon/scriptlint/commit/f586cc0))

## [2.0.1](https://github.com/peerigon/scriptlint/compare/v2.0.0...v2.0.1) (2020-02-24)

### Bug Fixes

* **build:** Fix CLI usage :scream: ([cb24eb9](https://github.com/peerigon/scriptlint/commit/cb24eb9))

# [2.0.0](https://github.com/peerigon/scriptlint/compare/v1.4.0...v2.0.0) (2020-02-24)

* Release (#20) ([c9a58be](https://github.com/peerigon/scriptlint/commit/c9a58be)), closes [#20](https://github.com/peerigon/scriptlint/issues/20) [#21](https://github.com/peerigon/scriptlint/issues/21) [#22](https://github.com/peerigon/scriptlint/issues/22)

### BREAKING CHANGES

* you may need to load your configurations differently

* chore(tests): improved tests

* feat(core): scripts can be passed to the module directly as a parameter

* fix(code): eslint --fix

* chore(readme): Update README

# [1.4.0](https://github.com/peerigon/scriptlint/compare/v1.3.2...v1.4.0) (2020-02-11)

### Bug Fixes

* **refactoring:** move tests into seperate dir ([e6c83b9](https://github.com/peerigon/scriptlint/commit/e6c83b9))
* Improve typing and defaults of CLI command handling ([febdf8d](https://github.com/peerigon/scriptlint/commit/febdf8d))

### Features

* **CLI:** opationally take package.json or project dir as argument and fallback to process.cwd() ([2b36142](https://github.com/peerigon/scriptlint/commit/2b36142))
* **structure:** make scriptlint usable as a required module ([048e35d](https://github.com/peerigon/scriptlint/commit/048e35d))

## [1.3.2](https://github.com/peerigon/scriptlint/compare/v1.3.1...v1.3.2) (2020-02-03)

### Bug Fixes

* **dependencies:** update scriptlint dependency to test re-release 😜 ([1158222](https://github.com/peerigon/scriptlint/commit/1158222))

## [1.3.1](https://github.com/peerigon/scriptlint/compare/v1.3.0...v1.3.1) (2020-02-03)

### Bug Fixes

* **readme:** Add missing parts of the README ([08726a9](https://github.com/peerigon/scriptlint/commit/08726a9))

# [1.3.0](https://github.com/peerigon/scriptlint/compare/v1.2.0...v1.3.0) (2020-02-03)

### Bug Fixes

* **CLI:** add `-V` option to CLI ([4925cc9](https://github.com/peerigon/scriptlint/commit/4925cc9))

### Features

* **cli:** JSON output and config inspection (-j -c) ([0ada14f](https://github.com/peerigon/scriptlint/commit/0ada14f))

# [1.2.0](https://github.com/peerigon/scriptlint/compare/v1.1.1...v1.2.0) (2020-01-30)

### Bug Fixes

* **dev:** whoops, forgot chmod in new package.json script ([850f0c0](https://github.com/peerigon/scriptlint/commit/850f0c0))
* **rules:** improve reporting on no-aliases ([c2e406b](https://github.com/peerigon/scriptlint/commit/c2e406b))
* **rules:** improve reporting on prepost-trigger-defined ([94360f0](https://github.com/peerigon/scriptlint/commit/94360f0))
* **tests:** fail test for invalid rule implementation ([e9c1568](https://github.com/peerigon/scriptlint/commit/e9c1568))

### Features

* **rules:** forbid certain script contents by regex ([0e54bac](https://github.com/peerigon/scriptlint/commit/0e54bac))

## [1.1.1](https://github.com/peerigon/scriptlint/compare/v1.1.0...v1.1.1) (2020-01-27)

### Bug Fixes

* **release:** make release action build stuff first :D ([9b34329](https://github.com/peerigon/scriptlint/commit/9b34329))

# [1.1.0](https://github.com/peerigon/scriptlint/compare/v1.0.0...v1.1.0) (2020-01-27)

### Features

* add more badges to the README ([7e950ab](https://github.com/peerigon/scriptlint/commit/7e950ab))

# 1.0.0 (2020-01-27)

### Bug Fixes

* bug with default npm scripts ([969ce88](https://github.com/peerigon/scriptlint/commit/969ce88))

repo_name: peerigon/scriptlint

repo_language: TypeScript

mime_type: text/plain


text:
global.console = {
	log: jest.fn(),

	// Keep native behaviour for other methods, use those to print out things in your own tests, not `console.log`
	error: console.error,
	warn: console.warn,
	info: console.info,
	debug: console.debug
};


text:
import type { Config } from "jest";

const config: Config = {
	preset: "ts-jest",
	testEnvironment: "node",
	modulePathIgnorePatterns: ["out-ts", "dist"],
	setupFilesAfterEnv: ["<rootDir>/jest-setup.js"],
	collectCoverageFrom: [
		"src/**/*.ts",
		"!src/index.ts",
		"!src/cli.ts",
		"!src/errors.ts",
	],
};

export default config;


text:
[![scriptlint status](https://img.shields.io/endpoint?url=https://scriptlint.peerigon.io/api/shield/scriptlint/latest)](https://scriptlint.peerigon.io/issues/scriptlint/latest)
[![npm version badge](https://img.shields.io/npm/v/scriptlint?style=flat-square)](https://npmjs.com/package/scriptlint)
[![dependency badge](https://img.shields.io/librariesio/release/npm/scriptlint?style=flat-square)](https://libraries.io/npm/scriptlint)
[![Issue badge](https://img.shields.io/github/issues/peerigon/scriptlint?style=flat-square)](https://github.com/peerigon/scriptlint/issues)
[![CI badge](https://github.com/peerigon/scriptlint/workflows/ci/badge.svg)](https://github.com/peerigon/scriptlint/actions?query=workflow%3Aci)

# scriptlint

Enforceable standards for your package.json scripts – like eslint for `npm run`

⚠ Requires nodejs >= 14.x.x

## Intro

`package.json` scripts are an integral part of the Node dev experience: we use them to start our projects, run our dev environments and for all kinds of formatting, linting and tooling in general. They are just as important as our code. Yet we don't treat them with the same meticulous attention to detail. **Scripts need :heart: too!**

One of the main goals for scriptlint was to enable people to use memorable and consistent script names across their projects. Tools like [nps](https://github.com/sezna/nps) are great when you have to organize scripts with a certain level of complexity, but they don't help you with the structure and naming of your scripts.

This is where the scriptlint CLI shines: it makes best practices outlined in this documentation enforceable throughout your project(s). Think of it as eslint for your `"scripts"` section.

## Rules

Here's the tl;dr of all the best practices we consider the "`scriptlint` standard"

Your `package.json`'s `"scripts"` section should…

-   have a `test` script that is not the default script from `npm init`
-   have a `dev` script and a `start` script
-   _abstract script names from their implementation (`test`, not `jest`)_
-   _use namespaces to categorize scripts (`"test:unit": "jest"`)_
-   _use `:` as a namespace separator_
-   _have the scripts in alphabetic order_
-   _have a trigger script for all hooks (ex: if you have `prefoobar`, there must be a `foobar` script)_
-   _use `camelCase` for all script names_
-   _not alias `devDependencies` (no `"jest": "jest"`)_
-   _not use `&&` or `&` for sequential or parallel script execution_

(_italic = strict rule_)

[Read more about the standard rules here](https://github.com/peerigon/scriptlint/wiki/The-scriptlint-%22standard%22-tl%3Bdr)

## Usage

Install locally:

`npm install scriptlint -D` (or `yarn add scriptlint -D`)

… then run `npx scriptlint --strict`

[Read about configuration here](https://github.com/peerigon/scriptlint/wiki/Configuration)

# Documentation

Motivation

The
scriptlint "standard" tl;dr

The
scriptlint
"standard"

Rules enforceable via the scriptlint CLI

Minimum
rules

mandatory-start

mandatory-dev

mandatory-test

no-default-test

Strict rules

uses-allowed-namespace

alphabetic-order

correct-casing

no-aliases

prepost-trigger-defined

no-unix-double-ampersand

no-unix-single-ampersand

Best
practices

The scriptlint CLI

Installation

Usage

Configuration

Extending

Use as a
JavaScript module

Contributing to
scriptlint

## Badge

Would you like a scriptlint badge for your project readme? No problem: have a look at https://scriptlint.peerigon.io/ or adapt the snippet below:

```markdown
[![scriptlint status](https://img.shields.io/endpoint?url=https://scriptlint.peerigon.io/api/shield/scriptlint/latest)](https://scriptlint.peerigon.io/issues/scriptlint/latest)
```

---

## Sponsors

[](https://peerigon.com)


text:
name: release
on:
  push:
    branches:
      - main
jobs:
  prepare:
    runs-on: ubuntu-latest
    if: "! contains(github.event.head_commit.message, '[skip ci]')"
    steps:
      - run: echo "${{ github.event.head_commit.message }}"
  publish:
    needs: prepare
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # pin@v2
        with:
          fetch-depth: 0
      - name: Setup Node.js
        uses: actions/setup-node@04c56d2f954f1e4c69436aa54cfef261a018f458 # pin@v2
        with:
          node-version: 18
      - name: Install dependencies
        run: |
          npm install
          npm run build
      - name: Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npx semantic-release


text:
name: ci

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [14.x, 16.x, 18.x]

    steps:
      - uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # pin@v2
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@04c56d2f954f1e4c69436aa54cfef261a018f458 # pin@v2
        with:
          node-version: ${{ matrix.node-version }}
      - name: npm install, build, and test
        run: |
          npm install
          npm run build --if-present
          npm test
        env:
          CI: true


text:
import EditJson from "./editJson";
import { PackageScripts } from "./types";
import { filterPackageScriptsByKeys } from "./utils";

export default (wd: string) => {
	const file = new EditJson(wd);

	return {
		readPackageScripts: (ignores: Array<string>): PackageScripts => {
			const { scripts } = file.get();

			return filterPackageScriptsByKeys(scripts, ignores);
		},

		writePackageScripts: (scripts: PackageScripts) => {
			file.set("scripts", scripts);

			return file.save();
		},
	};
};


text:
/*
·---------·
| {scr:pt |
|   l:nt} |
|         |
|         |
·---------·
*/

import module from "./module";

export = module;


text:
import { Rule, PackageScripts, JsonMessage } from "./types";
import { makeMessage, patchScriptObjectEntry } from "./utils";
import { ValidationFunctionInvalidError } from "./errors";

const execute = (
	rules: Array<Rule>,
	scripts: PackageScripts,
	warning?: (template: string, values?: string | Array<string>) => void,
	configFix = false
): [Array<JsonMessage>, PackageScripts, number] => {
	/**
	 * keep track of everything
	 */

	const issues: Array<JsonMessage> = [];
	let issuesFixed = 0;

	const patchScripts = (newScripts: PackageScripts) => {
		issuesFixed++;
		scripts = newScripts;
	};

	/**
	 * execution functions
	 */

	const executeObjectRule = ({ validate, message, name, fix }: Rule) => {
		if (typeof validate !== "function") {
			throw new ValidationFunctionInvalidError(
				`Rule validation function is not a function (${name})`
			);
		}

		const validationResult = validate(scripts);

		const fixable = typeof fix === "function";

		/**
		 * validate
		 */

		const valid =
			typeof validationResult === "boolean"
				? validationResult
				: validationResult.length < 1;

		if (valid) {
			return;
		}

		/**
		 * warn of invalidity
		 */
		const warningMessage =
			typeof validationResult === "boolean"
				? `${message} (${name})`
				: makeMessage(`${message} (${name})`, {
						names: validationResult.join(", "),
				  });

		/**
		 * potentially fix
		 */

		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
		if (configFix && fixable && fix) {
			patchScripts(fix(scripts));

			return;
		}

		/**
		 * keep track of everything
		 */

		issues.push({
			name,
			affected: undefined,
			type: "warning", // at some point we should really use this
			message: warningMessage,
		});

		if (typeof warning === "function") {
			warning(warningMessage, validationResult);
		}
	};

	const executeEntryRule = ({ validate, message, name, fix }: Rule) => {
		if (typeof validate !== "function") {
			throw new ValidationFunctionInvalidError(name);
		}

		const fixable = typeof fix === "function";
		const pairs = Object.entries(scripts);

		/**
		 * iterate all the scripts
		 */

		pairs.forEach(([key, value]) => {
			const valid = validate(key, value, scripts);

			if (valid) {
				return;
			}

			const warningMessage = makeMessage(`${message} (${name})`, {
				name: key,
			});

			/**
			 * potentially fix
			 */

			// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
			if (configFix && fixable && fix) {
				const [toKey, fixedValue] = fix(key, value);

				const fixedScripts = patchScriptObjectEntry(
					scripts,
					key,
					toKey,
					fixedValue
				);

				patchScripts(fixedScripts);

				return;
			}

			/**
			 * keep track of everything
			 */

			issues.push({
				name,
				affected: key,
				type: "warning", // at some point we should really use this
				message: warningMessage,
			});

			if (typeof warning === "function") {
				warning(warningMessage, key);
			}
		});
	};

	/**
	 * and go!
	 */

	rules.forEach((rule) => {
		if (rule.isObjectRule) {
			executeObjectRule(rule);
		} else {
			executeEntryRule(rule);
		}
	});

	return [issues, scripts, issuesFixed];
};

export default execute;


text:
import { Config } from "./types";

export const PROCESS_EXIT_ERROR = 1;
export const PROCESS_EXIT_OK = 0;

export const DEFAULT_CONFIG: Config = {
	strict: false,
	fix: false,
	json: false,
	config: false,
	packageFile: undefined,
	rules: {},
	customRules: [],
	ignoreScripts: [],
};

export const PROJECT_NAME = "scriptlint";

export const NAME_REGEX = /^[\d:A-Za-z]+$/;

export const NAMESPACES = [
	"build",
	"dev",
	"lint",
	"format",
	"lint",
	"other",
	"report",
	"script",
	"setup",
	"start",
	"test",
];

export const DEFAULT_NPM_HOOKS = [
	"install",
	"postinstall",
	"postpack",
	"postpublish",
	"postrestart",
	"postshrinkwrap",
	"poststart",
	"poststop",
	"posttest",
	"postuninstall",
	"postversion",
	"preinstall",
	"precommit",
	"prepack",
	"prepare",
	"prepublish",
	"prepublishOnly",
	"prerestart",
	"preshrinkwrap",
	"prestart",
	"prestop",
	"pretest",
	"preuninstall",
	"preversion",
	"publish",
	"release",
	"restart",
	"shrinkwrap",
	"stop",
	"uninstall",
	"version",
];


text:
import defaultRuleSets from "./defaultRuleSets";
import defaultRules from "./rules";
// Types
import { Rule } from "./types";

type RulesConfig = {
	[key: string]: boolean;
};

export const getRuleByName = (
	rules: Array<Rule>,
	name: string
): Rule | null => {
	const filtered = rules.filter((r: Rule) => r.name === name);

	if (filtered.length < 1) {
		return null;
	}

	return filtered[0];
};

const loadDefaultRulesFromSet = (strict: boolean): Array<Rule> => {
	if (strict) {
		return defaultRuleSets.strict
			.map((name: string) => getRuleByName(defaultRules, name))
			.filter((r): r is Rule => r !== null);
	}

	return defaultRuleSets.default
		.map((name: string) => getRuleByName(defaultRules, name))
		.filter((r): r is Rule => r !== null);
};

export const loadRulesFromRuleConfig = (
	strict: boolean,
	rulesConfig?: RulesConfig,
	customRules?: Array<Rule>
): Array<Rule> => {
	const rules = loadDefaultRulesFromSet(strict);

	const loadedCustomRules =
		rulesConfig && customRules
			? customRules.filter((cr: Rule) => rulesConfig[cr.name])
			: [];

	const loadedRules = [...loadedCustomRules, ...rules];

	if (!rulesConfig) {
		return loadedRules;
	}

	return loadedRules
		.map((rule: Rule) => {
			if (rule.name in rulesConfig && rulesConfig[rule.name] === false) {
				return null;
			}

			return rule;
		})
		.filter((r): r is Rule => r !== null);
};


text:
#!/usr/bin/env node
// eslint-disable-next-line node/shebang
import userPackageScriptContext from "./userPackageScripts";
import { loadRulesFromRuleConfig } from "./loadRules";
import { ConfigError } from "./errors";
import execute from "./execute";
import { DEFAULT_CONFIG } from "./constants";
import { Config } from "./types";
import { makePackageFilePath } from "./utils";

export default (moduleConfig: Partial<Config>) => {
	/**
	 * validate config
	 */

	if (moduleConfig.packageFile && moduleConfig.packageScripts) {
		throw new ConfigError(
			"Either specify a package.json location or a scripts object but not both" +
				JSON.stringify(moduleConfig)
		);
	}

	if (!moduleConfig.packageFile && !moduleConfig.packageScripts) {
		throw new ConfigError(
			"You have to specify a package.json location or a scripts object"
		);
	}

	const config = { ...DEFAULT_CONFIG, ...moduleConfig };

	/**
	 * find scripts
	 */
	let scripts = moduleConfig.packageScripts ?? {};

	if (!moduleConfig.packageScripts && moduleConfig.packageFile) {
		const { readPackageScripts } = userPackageScriptContext(
			makePackageFilePath(config.packageFile ?? "")
		);

		scripts = readPackageScripts(config.ignoreScripts);
	}

	/**
	 * ok go!
	 */
	const rules = loadRulesFromRuleConfig(
		config.strict,
		config.rules,
		config.customRules
	);

	const [issues, fixedScripts] = execute(
		rules,
		scripts,
		() => {},
		config.fix
	);

	return {
		issues,
		scripts: config.fix ? fixedScripts : scripts,
	};
};


text:
import { cosmiconfigSync } from "cosmiconfig";
import { Config as CosmiconfigConfig } from "cosmiconfig/dist/types";
import { PROJECT_NAME, DEFAULT_CONFIG } from "./constants";
import { ConfigError } from "./errors";
// Types
import { Config } from "./types";

export const sanitizeConfig = (loadedConfig: CosmiconfigConfig): Config => {
	const sanitized = { ...DEFAULT_CONFIG, ...loadedConfig };

	Object.keys(sanitized).forEach((key: string) => {
		const keyExists = Object.keys(DEFAULT_CONFIG).includes(key);

		if (!keyExists) {
			// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
			delete sanitized[key];
			throw new ConfigError(`unknown config key "${key}"`);
		}
	});

	// eslint-disable-next-line @typescript-eslint/no-unsafe-return
	return sanitized;
};

const loadConfig = (name = PROJECT_NAME): Config => {
	const explorerSync = cosmiconfigSync(name);
	const searchedFor = explorerSync.search();

	if (!searchedFor) {
		return DEFAULT_CONFIG;
	}

	return sanitizeConfig(searchedFor.config);
};

export default loadConfig;


text:
export class ConfigError extends Error {
	constructor(message: string) {
		super(message);
		Object.setPrototypeOf(this, ConfigError.prototype);
	}
}

export class ValidationFunctionInvalidError extends Error {
	constructor(name: string) {
		super(`Rule validation function is not a function (${name})`);
		Object.setPrototypeOf(this, ValidationFunctionInvalidError.prototype);
	}
}

export class PackageFileNotFoundError extends Error {
	constructor(path: string) {
		super("package.json could not be found at this location: " + path);
		Object.setPrototypeOf(this, PackageFileNotFoundError.prototype);
	}
}


text:
import fs from "fs";
import path from "path";
import { PackageFileNotFoundError } from "./errors";
import { PackageScripts, Values } from "./types";

export const makePackageFilePath = (packageFile: string): string => {
	// resolve package.json path
	packageFile = packageFile.endsWith("/package.json")
		? packageFile
		: packageFile + "/package.json";

	packageFile = path.resolve(packageFile);

	// does it exist?
	if (!fs.existsSync(packageFile)) {
		throw new PackageFileNotFoundError(packageFile);
	}

	return packageFile;
};

export const slugify = (str: string): string =>
	str
		.trim()
		.toLowerCase()
		.replace(/[^A-Za-z-]/g, "-")
		.replace(/^-/g, "")
		.replace(/-+/g, "-")
		.replace(/-$/g, "");

export const filterPackageScriptsByKeys = (
	raw: PackageScripts,
	removes: Array<string>
): PackageScripts => {
	return Object.keys(raw)
		.filter((k) => !removes.includes(k))
		.reduce((obj: PackageScripts, key: string) => {
			obj[key] = raw[key];

			return obj;
		}, {});
};

export const makeMessage = (template: string, values: Values): string => {
	let message = template;

	if (values !== undefined) {
		const pairs = Object.entries(values);

		pairs.forEach(([key, value]) => {
			message = message.replace(`{{${key}}}`, value);
		});
	}

	return message;
};

const fromEntries = (iterable: Array<[string, string]>): PackageScripts => {
	return [...iterable].reduce((obj: PackageScripts, [key, val]) => {
		obj[key] = val;

		return obj;
	}, {});
};

export const patchScriptObjectEntry = (
	scripts: PackageScripts,
	fromKey: string,
	toKey: string,
	value: string
) =>
	fromEntries(
		Object.entries(scripts).map(([k, v]) => {
			return k === fromKey ? [toKey, value] : [k, v];
		})
	);


text:
/* eslint-disable no-process-exit */
import loadUserConfig from "./userConfig";
import loadCliConfig from "./cliConfig";
import { makePackageFilePath } from "./utils";
import userPackageScriptContext from "./userPackageScripts";
import { loadRulesFromRuleConfig } from "./loadRules";
import execute from "./execute";
import { success, warning, dump, error } from "./consoleReporter";
import {
	DEFAULT_CONFIG,
	PROCESS_EXIT_ERROR,
	PROCESS_EXIT_OK,
} from "./constants";

/* istanbul ignore next */
const processExit = (code: number) => {
	if (typeof process.env.JEST_WORKER_ID === "undefined") {
		// eslint-disable-next-line node/no-process-exit
		process.exit(code);
	}
};

export default (argv: Array<string>) => {
	try {
		/**
		 * config assembly
		 */

		const userConfig = loadUserConfig();
		const cliConfig = loadCliConfig(argv);
		const config = {
			...DEFAULT_CONFIG,
			...{ json: false },
			...userConfig,
			...cliConfig,
		};

		// output the config (--config) but only if we don't want JSON output on the CLI
		if (!config.json && config.config) {
			const json = JSON.stringify(config, null, 2);

			// eslint-disable-next-line no-console
			console.log(`\n\n${json}\n\n`);
		}

		/**
		 * package.json
		 */

		const { writePackageScripts, readPackageScripts } =
			userPackageScriptContext(
				makePackageFilePath(config.packageFile ?? process.cwd())
			);

		const scripts = readPackageScripts(config.ignoreScripts);

		/**
		 * rule loading
		 */

		const rules = loadRulesFromRuleConfig(
			config.strict,
			config.rules,
			config.customRules
		);

		/**
		 * and go!
		 */
		const [issues, fixedScripts, issuesFixed] = execute(
			rules,
			scripts,
			warning,
			config.fix
		);

		if (config.fix && issuesFixed > 0) {
			writePackageScripts(fixedScripts);
			success(`Fixed ${issuesFixed} issue${issuesFixed > 1 ? "s" : ""}!`);
		}

		if (issues.length - issuesFixed === 0) {
			success("✨  All good");

			dump(config.json);

			processExit(PROCESS_EXIT_OK);
		}

		dump(config.json);
		processExit(PROCESS_EXIT_ERROR);
	} catch (err) {
		error((err as Error).message);
	}
};


text:
import fs from "fs";
import detectIndent, { Indent } from "detect-indent";
import { PackageFile } from "./types";
import { PackageFileNotFoundError } from "./errors";

export default class {
	path: string;
	indent: Indent;
	package: PackageFile;
	fileContents?: string;

	constructor(path: string) {
		this.path = path;
		this.fileContents = fs.readFileSync(path, "utf-8");
		if (!this.fileContents) {
			throw new PackageFileNotFoundError(path);
		}

		const fileParsed = JSON.parse(this.fileContents);

		if (!fileParsed.scripts) {
			fileParsed.scripts = {};
		}

		this.package = fileParsed;
		this.indent = detectIndent(this.fileContents);
	}

	get(): PackageFile {
		return this.package;
	}

	set(path: string, content: Record<string, unknown>) {
		this.package[path] = content;

		return this;
	}

	save() {
		const json = JSON.stringify(this.package, null, this.indent.indent);

		return fs.writeFileSync(this.path, json);
	}
}


text:
#!/usr/bin/env node
// eslint-disable-next-line node/shebang
import cliModule from "./cliModule";

cliModule(process.argv);


text:
import chalk from "chalk";
import { Values, MessageBuffer, MessageType } from "./types";
import { makeMessage } from "./utils";

const PREFIX = "⧙։⧘";
let stashed: MessageBuffer = [];

const stash = (message: string, type: MessageType): void => {
	stashed.push({
		message,
		type,
	});
};

export const warning = (template: string, values?: Values): void => {
	const message = makeMessage(template, values);

	stash(message, "warning");
};

export const success = (template: string, values?: Values): void => {
	const message = makeMessage(template, values);

	stash(message, "success");
};

export const dump = (jsonOutput: boolean): number => {
	const problemCount = stashed.length;

	if (jsonOutput) {
		// eslint-disable-next-line no-console
		console.log(JSON.stringify(stashed, null, "\t"));
	} else {
		stashed.forEach(({ message, type }) => {
			print(type, message);
		});
	}

	stashed = [];

	return problemCount;
};

export const error = (message: string): void => {
	print("error", message);
};

const print = (type: MessageType, message: string) => {
	switch (type) {
		case "error": {
			// eslint-disable-next-line no-console
			console.log(chalk.bold.red(`${PREFIX} [error] ${message}`));
			break;
		}

		case "warning": {
			const notUnderlined = chalk.yellow.bold(`${PREFIX} [${type}]`);
			const underlined = chalk.yellow.underline(message);

			// eslint-disable-next-line no-console
			console.log(`${notUnderlined} ${underlined}`);
			break;
		}
		case "success": {
			// eslint-disable-next-line no-console
			console.log(chalk.bold.gray(`${PREFIX} [✔] ${message}`));
			break;
		}
	}
};


text:
export type Rule = {
	isObjectRule: boolean;
	name: string;
	message: string;
	validate: unknown;

	fix?: (
		a: unknown,
		b?: unknown,
		c?: unknown
	) => PackageScripts & [string, string];
};

export type PackageScripts = {
	[key: string]: string;
};

export type Config = {
	strict: boolean;
	packageFile?: string;
	packageScripts?: PackageScripts;
	fix: boolean;
	json: boolean;
	config: boolean;
	rules: {
		[key: string]: boolean;
	};
	customRules: Array<Rule>;
	ignoreScripts: Array<string>;
};

export type PackageFile = {
	[key: string]: unknown;
	scripts: PackageScripts;
};

export type Values =
	| undefined
	| string
	| Array<string>
	| {
			[key: string]: string;
	  };

export type MessageType = "error" | "warning" | "success";

type Message = {
	message: string;
	type: MessageType;
};

export type MessageBuffer = Array<Message>;

export type JsonMessage = {
	name: string;
	type: string;
	message: string;
	affected: Values;
};


text:
import defaultRules from "./rules";
import { Rule } from "./types";

const ruleSets = {
	default: [
		"mandatory-test",
		"mandatory-start",
		"mandatory-dev",
		"no-default-test",
	],
	strict: defaultRules.map((r: Rule) => r.name),
};

export default ruleSets;


text:
import commander from "commander";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { version } = require("../package.json");

type CliConfig = {
	packageFile?: string;
	fix?: boolean;
	strict?: boolean;
	json?: boolean;
	config?: boolean;
};

export default (argv: Array<string>): CliConfig => {
	const program = new commander.Command();
	let packageFile;

	program
		.version(`${version}`)
		.arguments("[packageFile]")
		.action((arg: string) => {
			packageFile = arg;
		})
		.option("-s, --strict", "strict mode")
		.option("-j, --json", "JSON output")
		.option("-c, --config", "inspect the config")
		.option("-f, --fix", "autofixing");

	program.parse(argv);

	const options = program.opts();

	const cliConfig: CliConfig = { packageFile };

	if (options.fix !== undefined) {
		cliConfig.fix = options.fix;
	}

	if (options.strict !== undefined) {
		cliConfig.strict = options.strict;
	}

	if (options.json !== undefined) {
		cliConfig.json = options.json;
	}

	if (options.config !== undefined) {
		cliConfig.config = options.config;
	}

	return cliConfig;
};


text:
import { DEFAULT_NPM_HOOKS } from "../constants";

export default {
	name: "no-aliases",
	isObjectRule: false,
	message: "don't alias binaries, use npx/yarn instead ({{name}})",
	validate: (key: string, value: string) =>
		key !== value || DEFAULT_NPM_HOOKS.includes(key),
};


text:
import makeMandatory from "./mandatoryScriptFactory";
import makeForbidUnixOperators from "./noShellSpecificsFactory";
import noDefaultTest from "./no-default-test";
import correctCasing from "./correct-casing";
import noAliases from "./no-aliases";
import prePostTriggerDefined from "./prepost-trigger-defined";
import usesAllowedNamespace from "./uses-allowed-namespace";
import alphabeticOrder from "./alphabetic-order";
import { Rule } from "../types";

const rules: Array<Rule> = [
	makeMandatory("test"),
	makeMandatory("start"),
	makeMandatory("dev"),
	noDefaultTest,
	correctCasing,
	noAliases,
	usesAllowedNamespace,
	prePostTriggerDefined,
	alphabeticOrder,
	makeForbidUnixOperators(/rm /, "rm -rf", "rimraf"),
	makeForbidUnixOperators(
		/ && /,
		"unix double ampersand (&&)",
		"npm-run-all/run-s"
	),
	makeForbidUnixOperators(
		/ & /,
		"unix single ampersand (&)",
		"npm-run-all/run-p"
	),
];

export default rules;


text:
import { PackageScripts, Rule } from "../types";

export default (name: string): Rule => ({
	isObjectRule: true,
	name: `mandatory-${name}`,
	message: `must contain a "${name}" script`,
	validate: (scripts: PackageScripts) => Object.keys(scripts).includes(name),
});


text:
import { PackageScripts } from "../types";

export default {
	name: "no-default-test",
	isObjectRule: true,
	message: "`test` script can't be the default script",
	validate: (scripts: PackageScripts) => {
		if (Object.keys(scripts).includes("test")) {
			return scripts.test !== 'echo "Error: no test specified" && exit 1';
		}

		return true;
	},
};


text:
import { PackageScripts } from "../types";

export const sortScripts = (scripts: PackageScripts): PackageScripts =>
	Object.entries(scripts)
		.sort((a, b) => {
			return a < b ? -1 : 1;
		})
		.reduce((r, [k, v]) => ({ ...r, [k]: v }), {});

export default {
	name: "alphabetic-order",
	isObjectRule: true,
	message: "scripts must be in alphabetic order",
	validate: (scripts: PackageScripts) => {
		const sorted = sortScripts(scripts);

		return Object.keys(sorted).join("|") === Object.keys(scripts).join("|");
	},
	fix: (scripts: PackageScripts) => sortScripts(scripts),
};


text:
import { Rule } from "../types";
import { slugify } from "../utils";

export default (regex: RegExp, name: string, alternative?: string): Rule => {
	const slug = slugify(name);

	return {
		name: `no-${slug}`,
		isObjectRule: false,
		message: `Use of ${name} in script '{{name}}' is not allowed, consider using ${alternative}`,
		validate: (_: string, script: string): boolean | string => {
			return !regex.test(script);
		},
	};
};


text:
import { NAMESPACES, DEFAULT_NPM_HOOKS } from "../constants";

const validate = (key: string): boolean =>
	NAMESPACES.some((n) => key === n) ||
	NAMESPACES.some((n) => key.startsWith(`${n}:`)) ||
	DEFAULT_NPM_HOOKS.includes(key) ||
	(key.startsWith("pre") && validate(key.slice(3))) ||
	(key.startsWith("post") && validate(key.slice(4)));

export default {
	name: "uses-allowed-namespace",
	isObjectRule: false,
	message:
		'script name "{{name}}" should start with one of the allowed namespaces',
	validate,
	fix: (key: string, value: string) => [`other:${key}`, value],
};


text:
import { NAME_REGEX } from "../constants";

export default {
	name: "correct-casing",
	isObjectRule: false,
	message: 'script name "{{name}}" must be camel case',
	validate: (key: string) => NAME_REGEX.test(key),
};


text:
import { DEFAULT_NPM_HOOKS } from "../constants";
import { PackageScripts } from "../types";
import { filterPackageScriptsByKeys } from "../utils";

const getMissingHooks = (prefix: string, scripts: PackageScripts) => {
	const keys = Object.keys(scripts);
	const hooks = keys.filter((k: string) => k.startsWith(prefix));
	const hooksMissing: Array<string> = [];

	hooks.forEach((hookName: string) => {
		const triggerName = hookName.substr(prefix.length);

		if (!keys.includes(triggerName)) {
			hooksMissing.push(triggerName);
		}
	});

	return hooksMissing;
};

export default {
	name: "prepost-trigger-defined",
	isObjectRule: true,
	message:
		"some custom hooks ({{names}}) are missing their trigger script(s)",
	validate: (scripts: PackageScripts): boolean | Array<string> => {
		scripts = filterPackageScriptsByKeys(scripts, DEFAULT_NPM_HOOKS);
		const preHooksMissing = getMissingHooks("pre", scripts);
		const postHooksMissing = getMissingHooks("post", scripts);

		const allMissing = [
			...preHooksMissing.map((s) => `pre${s}`),
			...postHooksMissing.map((s) => `post${s}`),
		];

		return allMissing.length < 1 ? true : allMissing;
	},
};


text:
import {
	slugify,
	filterPackageScriptsByKeys,
	makeMessage,
	makePackageFilePath,
	patchScriptObjectEntry,
} from "../src/utils";
import { PackageFileNotFoundError } from "../src/errors";

jest.mock("fs");
jest.mock("path");

describe("patchScriptObjectEntry()", () => {
	expect(
		patchScriptObjectEntry(
			{
				bar: "1",
				foo: "2",
			},
			"bar",
			"xxx",
			"5"
		)
	).toEqual({
		xxx: "5",
		foo: "2",
	});
});

describe("slugify()", () => {
	it("should leave empty strings alone", () => {
		expect(slugify("")).toEqual("");
	});

	it("should already slugged strings alone", () => {
		expect(slugify("this-is-fine")).toEqual("this-is-fine");
	});

	it("should lowercase some stuff", () => {
		expect(slugify("LOWERCASE-THIS")).toEqual("lowercase-this");
	});

	it("should sluggify correctly 1", () => {
		expect(slugify("this is NOT cool! ")).toEqual("this-is-not-cool");
	});

	it("should sluggify correctly 2", () => {
		expect(slugify("!APPARENTLY\tSOMETHING WENT 🚨 WRONG!")).toEqual(
			"apparently-something-went-wrong"
		);
	});

	it("should sluggify correctly 3", () => {
		expect(slugify("rm -rf")).toEqual("rm-rf");
	});

	it("should sluggify correctly 4", () => {
		expect(slugify("unix-operators (&)")).toEqual("unix-operators");
	});
});

describe("filterPackageScriptsByKeys()", () => {
	it("filters objects by keys correctly", () => {
		expect(
			filterPackageScriptsByKeys(
				{ foo: "echo 1", bar: "echo 2", baz: "echo 3" },
				["bar"]
			)
		).toEqual({
			baz: "echo 3",
			foo: "echo 1",
		});
		expect(filterPackageScriptsByKeys({ foo: "echo 1" }, [])).toEqual({
			foo: "echo 1",
		});
		expect(filterPackageScriptsByKeys({}, [])).toEqual({});
	});
});

describe("makePackageFilePath()", () => {
	it("throws on file not found", () => {
		expect(() => {
			makePackageFilePath("foo/bar/baz");
		}).toThrowError(PackageFileNotFoundError);
	});

	it("works with package.json", () => {
		const path = makePackageFilePath("real/existing/path/package.json");

		expect(path).toEqual("real/existing/path/package.json");
	});

	it("works without package.json", () => {
		const path = makePackageFilePath("real/existing/path");

		expect(path).toEqual("real/existing/path/package.json");
	});
});

describe("makeMessage()", () => {
	it("compiles correctly", () => {
		expect(makeMessage("foo {{bar}}", { bar: "test" })).toEqual("foo test");
	});
});


text:
import cliConfig from "../src/cliConfig";

describe("cliConfig.ts", () => {
	it("should parse CLI params", () => {
		const { packageFile, ...config } = cliConfig([
			"foo/bin/node",
			"/usr/local/bin/scriptlint",
			"-f",
			"--json",
			"foo/bar/baz",
			"--strict",
			"-c",
		]);

		expect(packageFile).toBe("foo/bar/baz");

		expect(config).toEqual({
			config: true,
			fix: true,
			json: true,
			strict: true,
		});
	});
	it("should parse CLI params #2", () => {
		const pJName = "foo/bar/baz/package.json";

		const { packageFile, ...config } = cliConfig([
			"foo/bin/node",
			"/usr/local/bin/scriptlint",
			"-f",
			"--json",
			"--strict",
			"-c",
			pJName,
		]);

		expect(packageFile).toBe(pJName);

		expect(config).toEqual({
			config: true,
			fix: true,
			json: true,
			strict: true,
		});
	});

	it("should parse CLI params #3", () => {
		const { packageFile, ...config } = cliConfig([
			"foo/bin/node",
			"/usr/local/bin/scriptlint",
			"-f",
		]);

		expect(packageFile).not.toBeDefined();

		expect(config).toEqual({
			fix: true,
		});
	});

	it("should parse CLI params #3", () => {
		const { packageFile, ...config } = cliConfig(["foo", "bar"]);

		expect(config).toEqual({});
	});
});


text:
import scriptlint from "../src/module";

jest.mock("fs");
jest.mock("path");

describe("index.ts", () => {
	it("should export a function", () => {
		expect(typeof scriptlint).toBe("function");
	});

	it("should throw with too much config", () => {
		expect(() => {
			scriptlint({
				packageFile: "foo/bar/baz",
				packageScripts: {
					foo: "bar",
				},
			});
		}).toThrowError(/not both/);
	});

	it("should throw with too little config", () => {
		expect(() => {
			scriptlint({});
		}).toThrowError(/location or a scripts/);
	});

	it("should lint files", () => {
		expect(
			scriptlint({
				packageFile: "real/existing/path/package.json",
			})
		).toMatchSnapshot();
	});

	it("should lint correctly", () => {
		expect(
			scriptlint({
				strict: true,
				packageScripts: {},
			}).issues.length
		).toBe(3);
	});

	it("should lint correctly", () => {
		expect(
			scriptlint({
				strict: false,
				packageScripts: {
					dev: "echo 1",
					test: "echo 1",
					start: "echo 1",
				},
			}).issues.length
		).toBe(0);
	});

	it("should fix correctly", () => {
		expect(
			Object.keys(
				scriptlint({
					fix: true,
					strict: true,
					packageScripts: {
						start: "echo 1",
						test: "echo 1",
						dev: "echo 1",
						foo: "bar",
					},
				}).scripts
			)
		).toEqual(["dev", "other:foo", "start", "test"]);
	});
});


text:
import { loadRulesFromRuleConfig, getRuleByName } from "../src/loadRules";
import defaultRules from "../src/rules";

describe("loadRules.ts", () => {
	const defaultRulesLoaded = loadRulesFromRuleConfig(false);
	const strictRulesLoaded = loadRulesFromRuleConfig(true);

	it("loads custom rules", () => {
		const customRule = {
			name: "test-custom-rule-foobar",
			isObjectRule: false,
			message: "foobar",
			validate: () => true,
		};

		const rulesWithCustomRule = loadRulesFromRuleConfig(
			false,
			{
				[customRule.name]: true,
			},
			[customRule]
		);

		expect(
			rulesWithCustomRule
				.map((r) => r.name)
				.filter((r) => r === customRule.name)[0]
		).toBe(customRule.name);
	});

	it("loads correct amount of rules", () => {
		expect(strictRulesLoaded.length).toBe(defaultRules.length);
	});

	test("getRuleByName() nulls on unknown name", () => {
		expect(getRuleByName(defaultRulesLoaded, "foo")).toBe(null);
		expect(
			getRuleByName(defaultRulesLoaded, "mandatory-dev")
		).toMatchSnapshot();
	});

	test("loadRulesFromSet() excludes rules by config", () => {
		const rules = loadRulesFromRuleConfig(true, {
			"mandatory-dev": false,
			"mandatory-start": false,
			"mandatory-test": false,
		});

		expect(rules[0].name).toEqual("no-default-test");
	});

	test("loadRulesFromSet() adds custom rules", () => {
		const rules = loadRulesFromRuleConfig(
			true,
			{
				foobarbaz: true,
			},
			[
				{
					name: "foobarbaz",
					isObjectRule: true,
					message: "barbazfoo",
					validate: () => true,
				},
			]
		);

		expect(rules[0].name).toEqual("foobarbaz");
	});

	test("loadRulesFromSet() ignores custom rules that are not loaded", () => {
		const rules = loadRulesFromRuleConfig(false, {}, [
			{
				name: "foobarbaz",
				isObjectRule: true,
				message: "barbazfoo",
				validate: () => true,
			},
		]);

		expect(rules.map((r) => r.name).includes("foobarbaz")).toBe(false);
	});
});


text:
import context from "../src/userPackageScripts";

const { readPackageScripts, writePackageScripts } = context(
	"real/existing/path/package.json"
);

const setMock = jest.fn();
const saveMock = jest.fn();

jest.mock("../src/editJson", () =>
	jest.fn(() => ({
		get: () => ({
			scripts: {
				foo: "bar",
			},
		}),
		set: (path: string, content: Record<string, unknown>) =>
			setMock(path, content),
		save: () => saveMock(),
	}))
);

describe("userPackageScripts.ts", () => {
	it("reads package.json files", () => {
		const thisPackageJson = readPackageScripts([]);

		expect(Object.keys(thisPackageJson).length > 0).toBe(true);
	});

	it("fails when it no package.json was found", () => {
		try {
			readPackageScripts([]);

			expect(true).toBe(false);
		} catch (e) {
			expect((e as Error).message).toBeDefined();
		}
	});

	it("fails when package.json has no scripts", () => {
		try {
			readPackageScripts([]);

			expect(true).toBe(false);
		} catch (e) {
			expect((e as Error).message).toBeDefined();
		}
	});

	it("writes back to the file", () => {
		writePackageScripts({
			foo: "bar",
		});
		expect(setMock).toHaveBeenCalledWith("scripts", {
			foo: "bar",
		});
		expect(saveMock).toHaveBeenCalled();
	});
});


text:
import detectIndent from "detect-indent";
import EditJson from "../src/editJson";
import { PackageFileNotFoundError } from "../src/errors";

jest.mock("fs");

describe("editJson.ts", () => {
	describe("it throws on file not found", () => {
		expect(() => new EditJson("foo/bar/baz")).toThrow(
			PackageFileNotFoundError
		);
	});

	it("has a default scripts section", () => {
		const fileWithoutScripts = new EditJson(
			"real/existing/path/package-without-scripts.json"
		);

		expect(fileWithoutScripts.get().scripts).toEqual({});
	});

	const file = new EditJson("real/existing/path/package.json");

	it("reads files", () => {
		expect(file).toMatchSnapshot();
	});
	test("get()", () => {
		expect(file.get()).toEqual({ scripts: { foo: "bar" } });
	});

	test("set()", () => {
		expect(file.set("scripts", { foo: "bar" }).get()).toEqual({
			scripts: { foo: "bar" },
		});
	});

	it("should preserve indentation", () => {
		expect(file.indent).toEqual({ amount: 2, indent: "  ", type: "space" });
	});

	const saved = file.save();

	test("save()", () => {
		expect((saved as any).written).toBe(true);
	});

	it("respects indentation 1", () => {
		expect(detectIndent((saved as any).content).type).toEqual("space");
	});

	it("respects indentation 2", () => {
		const file2 = new EditJson("real/existing/path/package-with-tabs.json");

		file2.set("scripts", { foo: "bar" });
		const saved2 = file2.save();

		expect(detectIndent((saved2 as any).content).type).toEqual("tab");
	});
});

export {};


text:
import mockConsole from "jest-mock-console";
import { warning, dump, success, error } from "../src/consoleReporter";

describe("consoleReporter.ts", () => {
	test("should console.log", () => {
		warning("foo");
		warning("foo");
		warning("foo");

		expect(dump(false)).toBe(3);
	});

	test("error()", () => {
		const restoreConsole = mockConsole();

		error("foobar");
		// eslint-disable-next-line no-console
		expect(console.log).toHaveBeenCalled();
		restoreConsole();
	});

	test("success()", () => {
		const restoreConsole = mockConsole();

		success("foobar");
		dump(false);
		// eslint-disable-next-line no-console
		expect(console.log).toHaveBeenCalled();
		restoreConsole();
	});
});


text:
import execute from "../src/execute";
import { loadRulesFromRuleConfig } from "../src/loadRules";
import { ValidationFunctionInvalidError } from "../src/errors";

const rulesNonStrict = loadRulesFromRuleConfig(false);
const rulesStrict = loadRulesFromRuleConfig(true);

describe("execute.ts", () => {
	it("errors by default on empty scripts", () => {
		const [issues, fixed] = execute(rulesNonStrict, {});

		expect(fixed).toEqual({});
		expect(issues.map((i) => i.name)).toEqual([
			"mandatory-test",
			"mandatory-start",
			"mandatory-dev",
		]);
	});

	describe("errors for invalid validation functions", () => {
		const rules = [
			{
				isObjectRule: true,
				name: "foo",
				message: "bar",
				validate: "unknown",
			},
		];

		const scripts = {
			foo: "bar",
		};

		test("object rule", () => {
			expect(() => {
				execute(rules, scripts);
			}).toThrowError(ValidationFunctionInvalidError);
		});

		test("entry rule", () => {
			rules[0].isObjectRule = false;
			expect(() => {
				execute(rules, scripts);
			}).toThrowError(ValidationFunctionInvalidError);
		});
	});

	it("doesn't complain about correct scripts (default, fixing)", () => {
		const scripts = {
			dev: "echo 1",
			start: "echo 1",
			test: "echo 1",
		};

		const [issues, fixed] = execute(
			rulesNonStrict,
			scripts,
			() => {},
			true
		);

		expect(fixed).toEqual(scripts);
		expect(issues).toEqual([]);
	});

	it("complains about rule violations (strict) #1", () => {
		const [issues] = execute(rulesStrict, {
			foo: "echo 1",
		});

		expect(issues.map((i) => i.name)).toEqual([
			"mandatory-test",
			"mandatory-start",
			"mandatory-dev",
			"uses-allowed-namespace",
		]);
	});

	it("complains about rule violations (strict) #2", () => {
		const mockWarningFn = jest.fn();

		const [issues] = execute(
			rulesStrict,
			{
				dev: "echo 1",
				start: "echo 1",
				test: "echo 1",
				"preother:foobar": "echo 1",
			},
			mockWarningFn
		);

		expect(issues.map((i) => i.name)).toEqual([
			"prepost-trigger-defined",
			"alphabetic-order",
		]);
		expect(mockWarningFn).toHaveBeenCalled();
	});

	it("complains about rule violations (strict, fixed) #3", () => {
		const scripts = {
			"wrong-place-no-category-wrong-case": "echo 1",
			dev: "echo 1",
			start: "echo 1",
			test: "echo 1",
		};

		const fixedShouldBe = {
			dev: "echo 1",
			"other:wrong-place-no-category-wrong-case": "echo 1",
			start: "echo 1",
			test: "echo 1",
		};

		const mockWarningFn = jest.fn();
		const [issues, fixed] = execute(
			rulesStrict,
			scripts,
			mockWarningFn,
			true
		);

		expect(mockWarningFn).toHaveBeenCalled();
		expect(issues.map((i) => i.name)).toEqual(["correct-casing"]);

		expect(fixed).toEqual(fixedShouldBe);

		expect(Object.keys(fixed).join(", ")).toEqual(
			Object.keys(fixedShouldBe).join(", ")
		);
	});

	it("doesn't complain about correct scripts (default)", () => {
		const [issues] = execute(rulesStrict, {
			dev: "echo 1",
			prepublishOnly: "echo 1",
			start: "echo 1",
			test: "echo 1",
		});

		expect(issues).toEqual([]);
	});
});

export {};


text:
/* eslint-disable no-console */
import mockConsole from "jest-mock-console";
import cli from "../src/cliModule";

jest.mock("fs");
jest.mock("path");

let restoreConsole: any;
const processArgv = [process.argv[0], process.argv[1]];

beforeEach(() => {
	restoreConsole = mockConsole();
});

afterAll(() => {
	if (restoreConsole) {
		restoreConsole();
	}
});

describe("cli.ts", () => {
	it("should lint files", () => {
		cli([...processArgv, "real/existing/path/package.json"]);

		expect((console.log as any).mock.calls.length).toEqual(3);
	});

	it("should catch errors", () => {
		expect(() => {
			cli([...processArgv, "not/existing/package.json"]);
		}).not.toThrow();
	});

	it("should fix 1 issue", () => {
		cli([
			...processArgv,
			"real/existing/path/fixable/package.json",
			"--fix",
		]);

		expect((console.log as any).mock.calls[0][0]).toMatch(/Fixed 1 issue/);
	});

	it("should fix 2 issues", () => {
		cli([
			...processArgv,
			"real/existing/path/fixable2/package.json",
			"--fix",
		]);

		expect((console.log as any).mock.calls[0][0]).toMatch(/Fixed 2 issues/);
	});

	it("should be all good man", () => {
		cli([
			...processArgv,
			"real/existing/path/correct/package.json",
			"--fix",
		]);

		expect((console.log as any).mock.calls[0][0]).toMatch(/All good/);
	});

	it("prints --json", () => {
		cli([...processArgv, "real/existing/path/package.json", "--json"]);

		const calls = (console.log as any).mock.calls;

		expect(calls.length).toEqual(1);
		expect(JSON.parse(calls[0]).length).toEqual(3);
	});

	it("prints --config", () => {
		cli([...processArgv, "real/existing/path/package.json", "--config"]);

		const calls = (console.log as any).mock.calls;

		expect(JSON.parse(calls[0]).customRules).toEqual([]);
	});
});


text:
import { DEFAULT_CONFIG } from "../src/constants";
import loadConfig, { sanitizeConfig } from "../src/userConfig";

const validConfig = {
	strict: true,
	fix: false,
	json: false,
	config: false,
	rules: { foo: "bar" },
	ignoreScripts: ["foo"],
	customRules: [
		{
			isObjectRule: false,
			name: "foobar",
			message: "barbaz",
			validate: () => true,
		},
	],
};

const invalidConfig = {
	invalid: 3,
};

describe("userConfig.ts", () => {
	it("should sanitize configs: empty config => default", () => {
		expect(sanitizeConfig({})).toEqual(DEFAULT_CONFIG);
	});
	it("should sanitize configs: null => default", () => {
		expect(sanitizeConfig(null)).toEqual(DEFAULT_CONFIG);
	});
	it("should sanitize configs: invalid keys", () => {
		expect(() => {
			sanitizeConfig(invalidConfig);
		}).toThrowErrorMatchingSnapshot();
	});
	it("should sanitize configs: valid => valid", () => {
		expect(sanitizeConfig(validConfig)).toEqual(validConfig);
	});

	test("loadConfig()", () => {
		const loaded = loadConfig();

		expect(loaded).toMatchSnapshot();
	});

	test("loadConfig() with config missing", () => {
		const loaded = loadConfig("missing");

		expect(loaded).toBe(DEFAULT_CONFIG);
	});
});

export {};


text:
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`index.ts should lint files 1`] = `
{
  "issues": [
    {
      "affected": undefined,
      "message": "must contain a "test" script (mandatory-test)",
      "name": "mandatory-test",
      "type": "warning",
    },
    {
      "affected": undefined,
      "message": "must contain a "start" script (mandatory-start)",
      "name": "mandatory-start",
      "type": "warning",
    },
    {
      "affected": undefined,
      "message": "must contain a "dev" script (mandatory-dev)",
      "name": "mandatory-dev",
      "type": "warning",
    },
  ],
  "scripts": {
    "foo": "bar",
  },
}
`;


text:
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`userConfig.ts loadConfig() 1`] = `
{
  "config": false,
  "customRules": [],
  "fix": false,
  "ignoreScripts": [
    "foobar",
  ],
  "json": false,
  "packageFile": undefined,
  "rules": {
    "mandatory-dev": false,
  },
  "strict": true,
}
`;

exports[`userConfig.ts should sanitize configs: invalid keys 1`] = `"unknown config key "invalid""`;


text:
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`loadRules.ts getRuleByName() nulls on unknown name 1`] = `
{
  "isObjectRule": true,
  "message": "must contain a "dev" script",
  "name": "mandatory-dev",
  "validate": [Function],
}
`;


text:
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`editJson.ts reads files 1`] = `
default_1 {
  "fileContents": "{
  "scripts": {
    "foo": "bar"
  }
}",
  "indent": {
    "amount": 2,
    "indent": "  ",
    "type": "space",
  },
  "package": {
    "scripts": {
      "foo": "bar",
    },
  },
  "path": "real/existing/path/package.json",
}
`;


text:
import rule from "../../src/rules/correct-casing";

describe("correct-casing.ts", () => {
	it("should validate correctly", () => {
		expect(rule.validate("foobar")).toBe(true);
		expect(rule.validate("fooBar")).toBe(true);
		expect(rule.validate("foo:bar")).toBe(true);
		expect(rule.validate("foo-bar")).toBe(false);
		expect(rule.validate("foo_bar")).toBe(false);
	});
});


text:
import rule from "../../src/rules/no-default-test";

describe("no-default-test.ts", () => {
	it("should validate correctly", () => {
		expect(rule.validate({})).toBe(true);
		expect(
			rule.validate({
				test: "echo 1",
			})
		).toBe(true);
		expect(
			rule.validate({
				test: 'echo "Error: no test specified" && exit 1',
			})
		).toBe(false);
	});
});


text:
import factory from "../../src/rules/noShellSpecificsFactory";

describe("mandatoryScriptFactory.ts", () => {
	it("should validate correctly 1", () => {
		const rule = factory(/foobar/, "foobar", "barfoo");
		const validate = rule.validate;

		expect(typeof validate).toBe("function");

		if (typeof validate !== "function") {
			return;
		}

		expect(validate("scriptName123", "foobar --param 1 2 3")).toBe(false);

		expect(validate("scriptName123", "barfoo --param 1 2 3")).toBe(true);
	});

	it("should validate correctly 2", () => {
		const rule = factory(
			/ && /,
			"unix operators (&&)",
			"npm-run-all/run-s"
		);

		const validate = rule.validate;

		expect(typeof validate).toBe("function");

		if (typeof validate !== "function") {
			return;
		}

		expect(validate("scriptName123", "eslint && jest")).toBe(false);

		expect(validate("scriptName123", "run-s eslint jest")).toBe(true);
	});
});


text:
import rule from "../../src/rules/prepost-trigger-defined";

describe("prepost-trigger-defined.ts", () => {
	it("should validate correctly", () => {
		expect(rule.validate({})).toBe(true);
		expect(
			rule.validate({
				prefoo: "echo 1",
				foo: "echo 1",
			})
		).toBe(true);

		expect(
			rule.validate({
				prefoo: "echo 1",
			})
		).toEqual(["prefoo"]);

		expect(
			rule.validate({
				postfoo: "echo 1",
			})
		).toEqual(["postfoo"]);
	});
});


text:
import factory from "../../src/rules/mandatoryScriptFactory";

describe("mandatoryScriptFactory.ts", () => {
	it("should validate correctly", () => {
		const rule = factory("dev");
		const validate = rule.validate;

		expect(typeof validate).toBe("function");

		if (typeof validate !== "function") {
			return;
		}

		expect(
			validate({
				foo: "bar",
			})
		).toBe(false);

		expect(
			validate({
				dev: "bar",
			})
		).toBe(true);
	});
});


text:
import rule from "../../src/rules/no-aliases";

describe("no-aliases.ts", () => {
	it("should validate correctly", () => {
		expect(rule.validate("foo", "bar")).toBe(true);
		expect(rule.validate("release", "release")).toBe(true);
		expect(rule.validate("bar", "bar")).toBe(false);
	});
});


text:
import rule from "../../src/rules/uses-allowed-namespace";

describe("uses-allowed-namespace.ts", () => {
	it("should validate correctly", () => {
		expect(rule.validate("foo")).toBe(false);
		expect(rule.validate("other:foo")).toBe(true);
		expect(rule.validate("script:cms")).toBe(true);
		expect(rule.validate("preFoo")).toBe(false);
		expect(rule.validate("postFoo")).toBe(false);
		expect(rule.validate("preother:foo")).toBe(true);
		expect(rule.validate("postother:foo")).toBe(true);
	});
});


text:
import rule, { sortScripts } from "../../src/rules/alphabetic-order";

describe("alphabetic-order.ts", () => {
	const invalidScripts = {
		foo: "bar",
		aba: "cus",
	};

	describe("validate()", () => {
		it("should validate correctly", () => {
			expect(rule.validate({})).toBe(true);
			expect(rule.validate(invalidScripts)).toBe(false);
		});
	});

	it("should fix issues", () => {
		expect(rule.validate(rule.fix(invalidScripts))).toBe(true);
	});

	describe("sortScripts()", () => {
		it("should sort correctly", () => {
			expect(sortScripts({})).toEqual({});
			expect(
				Object.keys(
					sortScripts({
						x: "-1",
						a: "1",
						0: "2",
						b: "0",
					})
				)
			).toEqual(["0", "a", "b", "x"]);
		});
	});
});


text:
const search = () => {
	return {
		config: {
			strict: true,
			ignoreScripts: ["foobar"],
			rules: {
				"mandatory-dev": false
			}
		}
	};
};

const configs = {
	scriptlint: {
		search
	},
	missing: {
		search: () => null
	}
};

module.exports = {
	cosmiconfigSync: name => {
		return configs[name];
	}
};


text:
const path = jest.genMockFromModule("path");
path.resolve = (...p) => p.join("/");

module.exports = path;


text:
const path = require("path");

const fs = jest.genMockFromModule("fs");

const packageFile = {
	scripts: {
		foo: "bar"
	}
};

const packageFileFixable = {
	scripts: {
		"start": "echo 1",
		"test": "echo 1",
		"dev": "echo 1",
	}
};

const packageFileFixable2 = {
	scripts: {
		"start": "echo 1",
		"foo": "echo 1",
		"test": "echo 1",
		"dev": "echo 1",
	}
};

const packageFileCorrect = {
	scripts: {
		"dev": "echo 1",
		"other:foo": "echo 1",
		"start": "echo 1",
		"test": "echo 1",
	}
};

const mockFiles = {
	"real/existing/path/package.json": JSON.stringify(
		{ ...packageFile },
		null,
		2
	),
	"real/existing/path/correct/package.json": JSON.stringify(
		{ ...packageFileCorrect },
		null,
		2
	),
	"real/existing/path/fixable/package.json": JSON.stringify(
		{ ...packageFileFixable },
		null,
		2
	),
	"real/existing/path/fixable2/package.json": JSON.stringify(
		{ ...packageFileFixable2 },
		null,
		2
	),
	"real/existing/path/package-without-scripts.json": JSON.stringify(
		{},
		null,
		2
	),
	"real/existing/path/package-with-tabs.json": JSON.stringify(
		{ ...packageFile },
		null,
		"\t"
	)
};

function readFileSync(directoryPath) {
	return mockFiles[directoryPath];
}

function writeFileSync(path, content) {
	return {
		written: true,
		path,
		content
	};
}

function existsSync(directoryPath) {
	return Object.keys(mockFiles).includes(directoryPath);
}

fs.readFileSync = readFileSync;
fs.writeFileSync = writeFileSync;
fs.existsSync = existsSync;

module.exports = fs;


text:
Written by Lightbend <info@lightbend.com>

To the extent possible under law, the author(s) have dedicated all copyright and
related and neighboring rights to this software to the public domain worldwide.
This software is distributed without any warranty.

You should have received a copy of the CC0 Public Domain Dedication along with
this software. If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.

repo_language: Scala


text:
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem  Gradle startup script for Windows
@rem
@rem ##########################################################################

@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal

set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%

@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=

@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome

set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init

echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.

goto fail

:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe

if exist "%JAVA_EXE%" goto init

echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.

goto fail

:init
@rem Get command-line arguments, handling Windows variants

if not "%OS%" == "Windows_NT" goto win9xME_args

:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2

:win9xME_args_slurp
if "x%~1" == "x" goto execute

set CMD_LINE_ARGS=%*

:execute
@rem Setup the command line

set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar

@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%

:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd

:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1

:mainEnd
if "%OS%"=="Windows_NT" endlocal

:omega


text:
name := "play-scala-websocket-example"

version := "1.0-SNAPSHOT"

lazy val root = (project in file(".")).enablePlugins(PlayScala)

val akkaVersion = "2.5.18"

scalaVersion := "2.12.8"

libraryDependencies += guice
libraryDependencies += ws

libraryDependencies += "org.webjars" % "flot" % "0.8.3-1"
libraryDependencies += "org.webjars" % "bootstrap" % "3.3.6"

libraryDependencies += "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test
libraryDependencies += "com.typesafe.akka" %% "akka-stream-testkit" % akkaVersion % Test
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "4.0.1" % Test
libraryDependencies += "org.awaitility" % "awaitility" % "3.1.3" % Test

scalacOptions ++= Seq(
  "-feature",
  "-deprecation",
  "-Xfatal-warnings"
)


text:
#!/usr/bin/env sh

##############################################################################
##
##  Gradle start up script for UN*X
##
##############################################################################

# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
    ls=`ls -ld "$PRG"`
    link=`expr "$ls" : '.*-> \(.*\)$'`
    if expr "$link" : '/.*' > /dev/null; then
        PRG="$link"
    else
        PRG=`dirname "$PRG"`"/$link"
    fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null

APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`

# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""

# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"

warn () {
    echo "$*"
}

die () {
    echo
    echo "$*"
    echo
    exit 1
}

# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
  CYGWIN* )
    cygwin=true
    ;;
  Darwin* )
    darwin=true
    ;;
  MINGW* )
    msys=true
    ;;
  NONSTOP* )
    nonstop=true
    ;;
esac

CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar

# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
        # IBM's JDK on AIX uses strange locations for the executables
        JAVACMD="$JAVA_HOME/jre/sh/java"
    else
        JAVACMD="$JAVA_HOME/bin/java"
    fi
    if [ ! -x "$JAVACMD" ] ; then
        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
    fi
else
    JAVACMD="java"
    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi

# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
    MAX_FD_LIMIT=`ulimit -H -n`
    if [ $? -eq 0 ] ; then
        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
            MAX_FD="$MAX_FD_LIMIT"
        fi
        ulimit -n $MAX_FD
        if [ $? -ne 0 ] ; then
            warn "Could not set maximum file descriptor limit: $MAX_FD"
        fi
    else
        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
    fi
fi

# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi

# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
    JAVACMD=`cygpath --unix "$JAVACMD"`

    # We build the pattern for arguments to be converted via cygpath
    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
    SEP=""
    for dir in $ROOTDIRSRAW ; do
        ROOTDIRS="$ROOTDIRS$SEP$dir"
        SEP="|"
    done
    OURCYGPATTERN="(^($ROOTDIRS))"
    # Add a user-defined pattern to the cygpath arguments
    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
    fi
    # Now convert the arguments - kludge to limit ourselves to /bin/sh
    i=0
    for arg in "$@" ; do
        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option

        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
        else
            eval `echo args$i`="\"$arg\""
        fi
        i=$((i+1))
    done
    case $i in
        (0) set -- ;;
        (1) set -- "$args0" ;;
        (2) set -- "$args0" "$args1" ;;
        (3) set -- "$args0" "$args1" "$args2" ;;
        (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
        (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
        (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
        (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
        (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
        (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
    esac
fi

# Escape application args
save () {
    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
    echo " "
}
APP_ARGS=$(save "$@")

# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"

# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
  cd "$(dirname "$0")"
fi

exec "$JAVACMD" "$@"


text: MOVED TO https://github.com/playframework/play-samples


text:
# These settings are synced to GitHub by https://probot.github.io/apps/settings/
repository:
  homepage: "https://developer.lightbend.com/start/?group=play"
  topics: playframework, example, example-project, sample, sample-app, jvm, webapp
  private: false
  has_issues: true
  # We don't need projects in sample projects
  has_projects: false
  # We don't need wiki in sample projects
  has_wiki: false
  has_downloads: true
  default_branch: 2.7.x
  allow_squash_merge: true
  allow_merge_commit: false
  allow_rebase_merge: false

teams:
  - name: core
    permission: admin
  - name: integrators
    permission: write
  - name: write-bots
    permission: write

branches:
  - name: "[0-9].*.x"
    protection:
      # We don't require reviews for sample applications because they are mainly
      # updated by template-control, which is an automated process
      required_pull_request_reviews: null
      # Required. Require status checks to pass before merging. Set to null to disable
      required_status_checks:
        # Required. The list of status checks to require in order to merge into this branch
        contexts: ["Travis CI - Pull Request", "typesafe-cla-validator"]

# Labels: tailored list of labels to be used by sample applications
labels:
  - color: f9d0c4
    name: "closed:declined"
  - color: f9d0c4
    name: "closed:duplicated"
    oldname: duplicate
  - color: f9d0c4
    name: "closed:invalid"
    oldname: invalid
  - color: f9d0c4
    name: "closed:question"
    oldname: question
  - color: f9d0c4
    name: "closed:wontfix"
    oldname: wontfix
  - color: 7057ff
    name: "good first issue"
  - color: 7057ff
    name: "Hacktoberfest"
  - color: 7057ff
    name: "help wanted"
  - color: cceecc
    name: "status:backlog"
    oldname: backlog
  - color: b60205
    name: "status:block-merge"
    oldname: block-merge
  - color: b60205
    name: "status:blocked"
  - color: 0e8a16
    name: "status:in-progress"
  - color: 0e8a16
    name: "status:merge-when-green"
    oldname: merge-when-green
  - color: fbca04
    name: "status:needs-backport"
  - color: fbca04
    name: "status:needs-forwardport"
  - color: fbca04
    name: "status:needs-info"
  - color: fbca04
    name: "status:needs-verification"
  - color: 0e8a16
    name: "status:ready"
  - color: fbca04
    name: "status:to-review"
    oldname: review
  - color: c5def5
    name: "topic:build/tests"
  - color: c5def5
    name: "topic:dev-environment"
  - color: c5def5
    name: "topic:documentation"
  - color: c5def5
    name: "topic:jdk-next"
  - color: b60205
    name: "type:defect"
    oldname: bug
  - color: 0052cc
    name: "type:feature"
  - color: 0052cc
    name: "type:improvement"
    oldname: enhancement
  - color: 0052cc
    name: "type:updates"
  - color: bf0d92
    name: "type:template-control"
    oldname: template-control


text:
// Use the Play sbt plugin for Play projects
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.7.0")

addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.2")

addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.2")


text:
#!/usr/bin/env bash

set -e
set -o pipefail

# Using cut because TRAVIS_SCALA_VERSION is the full Scala
# version (for example 2.12.4), but Gradle expects just the
# binary version (for example 2.12)
scala_binary_version=$(echo $TRAVIS_SCALA_VERSION | cut -c1-4)

echo "+------------------------------+"
echo "| Executing tests using Gradle |"
echo "+------------------------------+"
./gradlew -Dscala.binary.version=$scala_binary_version check -i --stacktrace


text:
#!/usr/bin/env bash

set -e
set -o pipefail

echo "+----------------------------+"
echo "| Executing tests using sbt  |"
echo "+----------------------------+"
sbt ++$TRAVIS_SCALA_VERSION test


text:
import com.google.inject.AbstractModule
import play.api.libs.concurrent.AkkaGuiceSupport

class Module extends AbstractModule with AkkaGuiceSupport {
  import actors._

  override def configure(): Unit = {
    bindActor[StocksActor]("stocksActor")
    bindActor[UserParentActor]("userParentActor")
    bindActorFactory[UserActor, UserActor.Factory]
  }
}


text:
.perspective (@value) {
  -webkit-perspective: @value;
  -moz-perspective: @value;
  perspective: @value;
}

.transform (@value) {
  -webkit-transform: rotateY(@value);
  -moz-transform: rotateY(@value);
  transform: rotateY(@value);
}

.border-radius (@value) {
  -webkit-border-radius: @value;
  -moz-border-radius: @value;
  border-radius: @value;
}

body {
  margin-top: 50px;
}

.flip-container {
  .perspective(1000);
  margin-bottom: 20px;
  &:hover .flipper {
    .transform(10deg);
  }
  &.flipped .flipper {
  .transform(180deg);
  }
}

.flipper {
  height: 250px;

  background-color: #fafafa;
  border: 1px solid #ddd;

  .border-radius(4px);

  cursor: hand;
  cursor: pointer;

  -webkit-transition: 0.6s;
  -moz-transition: 0.6s;
  transition: 0.6s;

  -webkit-transform-style: preserve-3d;
  -moz-transform-style: preserve-3d;
  transform-style: preserve-3d;

  &:after {
    content: attr(data-content);
    position: absolute;
    top: -1px;
    left: -1px;
    padding: 3px 7px;
    font-size: 12px;
    font-weight: bold;
    background-color: #ffffff;
    border: 1px solid #ddd;
    color: #9da0a4;

    .border-radius(4px 0 4px 0);
  }
}

.chart-holder, .details-holder {
  position: absolute;
  width: 100%;
  height: 250px;
  top: 0px;
  left: 0px;

  -webkit-backface-visibility: hidden;
  -moz-backface-visibility: hidden;
  backface-visibility: hidden;
}

.chart-holder {
  z-index: 2;
  & p {
    position: absolute;
    bottom: 7px;
    right: 20px;
    font-size: 10px;
    color: #aaaaaa;
    font-style: italic;
  }
}

.details-holder {
  .transform(180deg);
  text-align: center;
  z-index: 1;
  & h4 {
    padding: 20px;
  }
  & .progress {
    padding: 20px;
    background: none;
    border: none;

    -webkit-box-shadow: none;
    -moz-box-shadow: none;
    box-shadow: none;
  }
  & img {
    height: 128px;
    width: 128px;
  }
}

.chart {
  position: relative;
  width: 920px;
  height: 210px;
  margin-top: 30px;
  margin-bottom: 10px;
  margin-left: 10px;
  margin-right: 10px;
}


text:
$ ->
  ws = new WebSocket $("body").data("ws-url")
  ws.onmessage = (event) ->
    message = JSON.parse event.data
    switch message.type
      when "stockhistory"
        populateStockHistory(message)
      when "stockupdate"
        updateStockChart(message)
      else
        console.log(message)

  $("#addsymbolform").submit (event) ->
    event.preventDefault()
    # send the message to watch the stock
    ws.send(JSON.stringify({symbol: $("#addsymboltext").val()}))
    # reset the form
    $("#addsymboltext").val("")

getPricesFromArray = (data) ->
  (v[1] for v in data)

getChartArray = (data) ->
  ([i, v] for v, i in data)

getChartOptions = (data) ->
  series:
    shadowSize: 0
  yaxis:
    min: getAxisMin(data)
    max: getAxisMax(data)
  xaxis:
    show: false

getAxisMin = (data) ->
  Math.min.apply(Math, data) * 0.9

getAxisMax = (data) ->
  Math.max.apply(Math, data) * 1.1

populateStockHistory = (message) ->
  chart = $("<div>").addClass("chart").prop("id", message.symbol)
  chartHolder = $("<div>").addClass("chart-holder").append(chart)
  chartHolder.append($("<p>").text("values are simulated"))
  detailsHolder = $("<div>").addClass("details-holder")
  flipper = $("<div>").addClass("flipper").append(chartHolder).append(detailsHolder).attr("data-content", message.symbol)
  flipContainer = $("<div>").addClass("flip-container").append(flipper).click (event) ->
    handleFlip($(this))
  $("#stocks").prepend(flipContainer)
  plot = chart.plot([getChartArray(message.history)], getChartOptions(message.history)).data("plot")

updateStockChart = (message) ->
  if ($("#" + message.symbol).size() > 0)
    plot = $("#" + message.symbol).data("plot")
    data = getPricesFromArray(plot.getData()[0].data)
    data.shift()
    data.push(message.price)
    plot.setData([getChartArray(data)])
    # update the yaxes if either the min or max is now out of the acceptable range
    yaxes = plot.getOptions().yaxes[0]
    if ((getAxisMin(data) < yaxes.min) || (getAxisMax(data) > yaxes.max))
      # reseting yaxes
      yaxes.min = getAxisMin(data)
      yaxes.max = getAxisMax(data)
      plot.setupGrid()
    # redraw the chart
    plot.draw()

handleFlip = (container) ->
  if (container.hasClass("flipped"))
    container.removeClass("flipped")
    container.find(".details-holder").empty()
  else
    container.addClass("flipped")
    # fetch stock details and tweet
    $.ajax
      url: "/sentiment/" + container.children(".flipper").attr("data-content")
      dataType: "json"
      context: container
      success: (data) ->
        detailsHolder = $(this).find(".details-holder")
        detailsHolder.empty()
        switch data.label
          when "pos"
            detailsHolder.append($("<h4>").text("The tweets say BUY!"))
            detailsHolder.append($("<img>").attr("src", "/assets/images/buy.png"))
          when "neg"
            detailsHolder.append($("<h4>").text("The tweets say SELL!"))
            detailsHolder.append($("<img>").attr("src", "/assets/images/sell.png"))
          else
            detailsHolder.append($("<h4>").text("The tweets say HOLD!"))
            detailsHolder.append($("<img>").attr("src", "/assets/images/hold.png"))
      error: (jqXHR, textStatus, error) ->
        detailsHolder = $(this).find(".details-holder")
        detailsHolder.empty()
        detailsHolder.append($("<h2>").text("Error: " + JSON.parse(jqXHR.responseText).error))
    # display loading info
    detailsHolder = container.find(".details-holder")
    detailsHolder.append($("<h4>").text("Determing whether you should buy or sell based on the sentiment of recent tweets..."))
    detailsHolder.append($("<div>").addClass("progress progress-striped active").append($("<div>").addClass("bar").css("width", "100%")))


text:
package stocks

import akka.NotUsed
import akka.stream.ThrottleMode
import akka.stream.scaladsl.Source

import scala.concurrent.duration._

/**
 * A stock is a source of stock quotes and a symbol.
 */
class Stock(val symbol: StockSymbol) {
  private val stockQuoteGenerator: StockQuoteGenerator = new FakeStockQuoteGenerator(symbol)

  private val source: Source[StockQuote, NotUsed] = {
    Source.unfold(stockQuoteGenerator.seed) { (last: StockQuote) =>
      val next = stockQuoteGenerator.newQuote(last)
      Some(next, next)
    }
  }

  /**
   * Returns a source of stock history, containing a single element.
   */
  def history(n: Int): Source[StockHistory, NotUsed] = {
    source.grouped(n).map(sq => new StockHistory(symbol, sq.map(_.price))).take(1)
  }

  /**
   * Provides a source that returns a stock quote every 75 milliseconds.
   */
  def update: Source[StockUpdate, NotUsed] = {
    source
      .throttle(elements = 1, per = 75.millis, maximumBurst = 1, ThrottleMode.shaping)
      .map(sq => new StockUpdate(sq.symbol, sq.price))
  }

  override val toString: String = s"Stock($symbol)"
}

trait StockQuoteGenerator {
  def seed: StockQuote
  def newQuote(lastQuote: StockQuote): StockQuote
}

class FakeStockQuoteGenerator(symbol: StockSymbol) extends StockQuoteGenerator {
  private def random: Double = scala.util.Random.nextDouble

  def seed: StockQuote = {
    StockQuote(symbol, StockPrice(random * 800))
  }

  def newQuote(lastQuote: StockQuote): StockQuote = {
    StockQuote(symbol, StockPrice(lastQuote.price.raw * (0.95 + (0.1 * random))))
  }
}

case class StockQuote(symbol: StockSymbol, price: StockPrice)

/** Value class for a stock symbol */
class StockSymbol private (val raw: String) extends AnyVal {
  override def toString: String = raw
}

object StockSymbol {
  import play.api.libs.json._ // Combinator syntax

  def apply(raw: String) = new StockSymbol(raw)

  implicit val stockSymbolReads: Reads[StockSymbol] = {
    JsPath.read[String].map(StockSymbol(_))
  }

  implicit val stockSymbolWrites: Writes[StockSymbol] = Writes {
    (symbol: StockSymbol) => JsString(symbol.raw)
  }
}

/** Value class for stock price */
class StockPrice private (val raw: Double) extends AnyVal {
  override def toString: String = raw.toString
}

object StockPrice {
  import play.api.libs.json._ // Combinator syntax

  def apply(raw: Double):StockPrice = new StockPrice(raw)

  implicit val stockPriceWrites: Writes[StockPrice] = Writes {
    (price: StockPrice) => JsNumber(price.raw)
  }
}

// Used for automatic JSON conversion
// https://www.playframework.com/documentation/2.6.x/ScalaJson

// JSON presentation class for stock history
case class StockHistory(symbol: StockSymbol, prices: Seq[StockPrice])

object StockHistory {
  import play.api.libs.json._ // Combinator syntax

  implicit val stockHistoryWrites: Writes[StockHistory] = new Writes[StockHistory] {
    override def writes(history: StockHistory): JsValue = Json.obj(
      "type" -> "stockhistory",
      "symbol" -> history.symbol,
      "history" -> history.prices
    )
  }
}

// JSON presentation class for stock update
case class StockUpdate(symbol: StockSymbol, price: StockPrice)

object StockUpdate {
  import play.api.libs.json._ // Combinator syntax

  implicit val stockUpdateWrites: Writes[StockUpdate] = new Writes[StockUpdate] {
    override def writes(update: StockUpdate): JsValue = Json.obj(
      "type" -> "stockupdate",
      "symbol" -> update.symbol,
      "price" -> update.price
    )
  }
}


text:
package actors

import akka.actor.{Actor, ActorLogging}
import akka.event.LoggingReceive
import stocks._

import scala.collection.mutable

/**
 * This actor contains a set of stocks internally that may be used by
 * all websocket clients.
 */
class StocksActor extends Actor with ActorLogging {
  import Messages._

  // May want to remove stocks that aren't viewed by any clients...
  private val stocksMap: mutable.Map[StockSymbol, Stock] = mutable.HashMap()

  def receive = LoggingReceive {
    case WatchStocks(symbols) =>
      val stocks = symbols.map(symbol => stocksMap.getOrElseUpdate(symbol, new Stock(symbol)))
      sender ! Stocks(stocks)
  }
}


text:
package actors

import javax.inject._

import akka.actor._
import akka.event.{LogMarker, MarkerLoggingAdapter}
import akka.stream._
import akka.stream.scaladsl._
import akka.util.Timeout
import akka.{Done, NotUsed}
import com.google.inject.assistedinject.Assisted
import play.api.libs.json._
import stocks._

import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}

/**
 * Creates a user actor that sets up the websocket stream.  Although it's not required,
 * having an actor manage the stream helps with lifecycle and monitoring, and also helps
 * with dependency injection through the AkkaGuiceSupport trait.
 *
 * @param stocksActor the actor responsible for stocks and their streams
 * @param ec          implicit CPU bound execution context.
 */
class UserActor @Inject()(@Assisted id: String, @Named("stocksActor") stocksActor: ActorRef)
                         (implicit mat: Materializer, ec: ExecutionContext)
  extends Actor {
  import Messages._

  // Useful way to mark out individual actors with websocket request context information...
  private val marker = LogMarker(name = self.path.name)
  implicit val log: MarkerLoggingAdapter = akka.event.Logging.withMarker(context.system, this.getClass)

  implicit val timeout = Timeout(50.millis)

  val (hubSink, hubSource) = MergeHub.source[JsValue](perProducerBufferSize = 16)
    .toMat(BroadcastHub.sink(bufferSize = 256))(Keep.both)
    .run()

  private var stocksMap: Map[StockSymbol, UniqueKillSwitch] = Map.empty

  private val jsonSink: Sink[JsValue, Future[Done]] = Sink.foreach { json =>
    // When the user types in a stock in the upper right corner, this is triggered,
    val symbol = (json \ "symbol").as[StockSymbol]
    addStocks(Set(symbol))
  }

  // If this actor is killed directly, stop anything that we started running explicitly.
  override def postStop(): Unit = {
    log.info(marker, s"Stopping actor $self")
    unwatchStocks(stocksMap.keySet)
  }

  /**
   * The receive block, useful if other actors want to manipulate the flow.
   */
  override def receive: Receive = {
    case WatchStocks(symbols) =>
      addStocks(symbols)
      sender() ! websocketFlow

    case UnwatchStocks(symbols) =>
      unwatchStocks(symbols)
  }

  /**
   * Generates a flow that can be used by the websocket.
   *
   * @return the flow of JSON
   */
  private lazy val websocketFlow: Flow[JsValue, JsValue, NotUsed] = {
    // Put the source and sink together to make a flow of hub source as output (aggregating all
    // stocks as JSON to the browser) and the actor as the sink (receiving any JSON messages
    // from the browse), using a coupled sink and source.
    Flow.fromSinkAndSourceCoupled(jsonSink, hubSource).watchTermination() { (_, termination) =>
      // When the flow shuts down, make sure this actor also stops.
      termination.foreach(_ => context.stop(self))
      NotUsed
    }
  }

  /**
   * Adds several stocks to the hub, by asking the stocks actor for stocks.
   */
  private def addStocks(symbols: Set[StockSymbol]): Future[Unit] = {
    import akka.pattern.ask

    // Ask the stocksActor for a stream containing these stocks.
    val future = (stocksActor ? WatchStocks(symbols)).mapTo[Stocks]

    // when we get the response back, we want to turn that into a flow by creating a single
    // source and a single sink, so we merge all of the stock sources together into one by
    // pointing them to the hubSink, so we can add them dynamically even after the flow
    // has started.
    future.map { (newStocks: Stocks) =>
      newStocks.stocks.foreach { stock =>
        if (! stocksMap.contains(stock.symbol)) {
          log.info(marker, s"Adding stock $stock")
          addStock(stock)
        }
      }
    }
  }

  /**
   * Adds a single stock to the hub.
   */
  private def addStock(stock: Stock): Unit = {
    // We convert everything to JsValue so we get a single stream for the websocket.
    // Make sure the history gets written out before the updates for this stock...
    val historySource = stock.history(50).map(sh => Json.toJson(sh))
    val updateSource = stock.update.map(su => Json.toJson(su))
    val stockSource = historySource.concat(updateSource)

    // Set up a flow that will let us pull out a killswitch for this specific stock,
    // and automatic cleanup for very slow subscribers (where the browser has crashed, etc).
    val killswitchFlow: Flow[JsValue, JsValue, UniqueKillSwitch] = {
      Flow.apply[JsValue]
        .joinMat(KillSwitches.singleBidi[JsValue, JsValue])(Keep.right)
        .backpressureTimeout(1.seconds)
    }

    // Set up a complete runnable graph from the stock source to the hub's sink
    val graph: RunnableGraph[UniqueKillSwitch] = {
      stockSource
        .viaMat(killswitchFlow)(Keep.right)
        .to(hubSink)
        .named(s"stock-${stock.symbol}-$id")
    }

    // Start it up!
    val killSwitch = graph.run()

    // Pull out the kill switch so we can stop it when we want to unwatch a stock.
    stocksMap += (stock.symbol -> killSwitch)
  }

  def unwatchStocks(symbols: Set[StockSymbol]): Unit = {
    symbols.foreach { symbol =>
      stocksMap.get(symbol).foreach { killSwitch =>
        killSwitch.shutdown()
      }
      stocksMap -= symbol
    }
  }
}

object UserActor {
  trait Factory {
    def apply(id: String): Actor
  }
}


text:
package actors

import javax.inject.Inject

import akka.actor._
import akka.event.LoggingReceive
import akka.stream.scaladsl._
import akka.util.Timeout
import play.api.Configuration
import play.api.libs.concurrent.InjectedActorSupport
import play.api.libs.json._
import stocks._

import scala.concurrent.ExecutionContext
import scala.concurrent.duration._

/**
 * Provide some DI and configuration sugar for new UserActor instances.
 */
class UserParentActor @Inject()(childFactory: UserActor.Factory,
                                configuration: Configuration)
                               (implicit ec: ExecutionContext)
  extends Actor with InjectedActorSupport with ActorLogging {

  import Messages._
  import UserParentActor._
  import akka.pattern.{ask, pipe}

  implicit val timeout = Timeout(2.seconds)

  private val defaultStocks = configuration.get[Seq[String]]("default.stocks").map(StockSymbol(_))

  override def receive: Receive = LoggingReceive {
    case Create(id) =>
      val name = s"userActor-$id"
      log.info(s"Creating user actor $name with default stocks $defaultStocks")
      val child: ActorRef = injectedChild(childFactory(id), name)
      val future = (child ? WatchStocks(defaultStocks.toSet)).mapTo[Flow[JsValue, JsValue, _]]
      pipe(future) to sender()
  }
}

object UserParentActor {
  case class Create(id: String)
}