├── .gitignore
├── src
├── fixtures
│ ├── c.tsx
│ ├── index.js
│ ├── main.ts
│ ├── a.ts
│ └── b.ts
├── lib.rs
├── context.rs
├── stats
│ ├── file_length.rs
│ ├── missing_switch_default.rs
│ ├── class_data_abstraction_coupling.rs
│ ├── binary_expression_complexity.rs
│ ├── parameter_number.rs
│ ├── anon_inner_length.rs
│ ├── function_length.rs
│ └── cyclomatic_complexity.rs
├── analysis.rs
├── stats.rs
├── utils
│ └── log.ts
├── commands
│ └── stat.ts
├── resolve.rs
├── project.rs
└── walker.rs
├── lib
├── nocuous_bg.wasm
├── nocuous.generated.d.ts
└── nocuous.generated.js
├── rust-toolchain.toml
├── .vscode
└── settings.json
├── .rustfmt.toml
├── .github
└── workflows
│ ├── publish.yml
│ └── ci.yml
├── cli.ts
├── Cargo.toml
├── LICENSE
├── deno.json
├── test.ts
├── README.md
├── mod.ts
└── Cargo.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 |
--------------------------------------------------------------------------------
/src/fixtures/c.tsx:
--------------------------------------------------------------------------------
1 | export function C() {
2 | return
;
3 | }
4 |
--------------------------------------------------------------------------------
/lib/nocuous_bg.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/h-o-t/nocuous/HEAD/lib/nocuous_bg.wasm
--------------------------------------------------------------------------------
/rust-toolchain.toml:
--------------------------------------------------------------------------------
1 | [toolchain]
2 | channel = "1.80.1"
3 | components = ["rustfmt", "clippy"]
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "deno.enable": true,
3 | "deno.lint": true,
4 | "cSpell.words": [
5 | "indexmap"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.rustfmt.toml:
--------------------------------------------------------------------------------
1 | # Copyright 2020-2021 the Kitson P. Kelly. All rights reserved. MIT license.
2 | max_width = 80
3 | tab_spaces = 2
4 | edition = "2018"
5 |
--------------------------------------------------------------------------------
/src/fixtures/index.js:
--------------------------------------------------------------------------------
1 | import { A } from "./a.ts";
2 | import { B } from "./b.ts";
3 | import { C } from "./c.tsx";
4 |
5 | new A();
6 | new B();
7 | C();
8 |
--------------------------------------------------------------------------------
/src/fixtures/main.ts:
--------------------------------------------------------------------------------
1 | import { A } from "./a.ts";
2 | import { B } from "./b.ts";
3 | import { C } from "./c.tsx";
4 |
5 | new A();
6 | new B();
7 | C();
8 |
--------------------------------------------------------------------------------
/src/fixtures/a.ts:
--------------------------------------------------------------------------------
1 | export class A {
2 | constructor() {
3 | const f = new F();
4 | f.u;
5 | }
6 | }
7 |
8 | class F {
9 | #u = new Uint8Array();
10 |
11 | get u() {
12 | return this.#u;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/fixtures/b.ts:
--------------------------------------------------------------------------------
1 | export class B {
2 | #a() {
3 | return undefined;
4 | }
5 |
6 | b() {
7 | return this.#a();
8 | }
9 |
10 | c(a: string, b: string, c: string, d: string, e: string) {
11 | return `${a}${b}${c}${d}${e}`;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 | on:
3 | push:
4 | branches:
5 | - main
6 |
7 | jobs:
8 | publish:
9 | runs-on: ubuntu-latest
10 |
11 | permissions:
12 | contents: read
13 | id-token: write
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 |
18 | - name: Publish package
19 | run: npx jsr publish
20 |
--------------------------------------------------------------------------------
/cli.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module
3 | */
4 |
5 | import { Command } from "@cliffy/command";
6 | import stat from "./src/commands/stat.ts";
7 |
8 | await new Command()
9 | .name("nocuous")
10 | .version("1.0.1")
11 | .action(function () {
12 | this.showHelp();
13 | })
14 | .description("Static code analysis for JavaScript and TypeScript.")
15 | .command("stat", stat)
16 | .parse(Deno.args);
17 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "nocuous"
3 | version = "1.1.0"
4 | edition = "2021"
5 | description = "Wasm bindings to peform static analysis of JavaScript and TypeScript code"
6 | authors = ["Kitson P. Kelly"]
7 | license = "MIT"
8 |
9 | [lib]
10 | crate-type = ["cdylib", "rlib"]
11 | name = "nocuous"
12 |
13 | [dependencies]
14 | anyhow = "1.0.75"
15 | deno_ast = { version = "0.42.1", features = ["cjs", "dep_analysis", "view", "visit"] }
16 | futures = "0.3.28"
17 | import_map = "0.20.1"
18 | indexmap = { version = "2.0.0", features = ["serde"] }
19 | js-sys = "0.3.64"
20 | lazy_static = "1.4.0"
21 | regex = "1.9.4"
22 | serde = { version = "1.0", features = ["derive"] }
23 | serde-wasm-bindgen = "0.6.5"
24 | url = "2.4.1"
25 | wasm-bindgen = { version = "=0.2.92", features = ["serde-serialize"] }
26 | wasm-bindgen-futures = "=0.4.42"
27 |
28 | [profile.release]
29 | codegen-units = 1
30 | incremental = true
31 | lto = true
32 | opt-level = "s"
33 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | mod analysis;
2 | mod context;
3 | mod project;
4 | mod resolve;
5 | mod stats;
6 | mod walker;
7 |
8 | use wasm_bindgen::prelude::*;
9 |
10 | use crate::resolve::resolve_url_or_path;
11 |
12 | #[wasm_bindgen]
13 | extern "C" {
14 | #[wasm_bindgen(js_namespace = console)]
15 | fn log(s: &str);
16 | }
17 |
18 | fn to_js_error(error: anyhow::Error) -> JsError {
19 | JsError::new(&error.to_string())
20 | }
21 |
22 | #[wasm_bindgen]
23 | pub async fn stats(
24 | roots: JsValue,
25 | load: js_sys::Function,
26 | maybe_resolve: Option,
27 | ) -> Result {
28 | let roots: Vec = serde_wasm_bindgen::from_value(roots)?;
29 | let roots = roots
30 | .iter()
31 | .filter_map(|root| resolve_url_or_path(root).ok())
32 | .collect();
33 | let mut project = project::Project::new(roots, load, maybe_resolve);
34 | let stats = project.stat().await.map_err(to_js_error)?;
35 | serde_wasm_bindgen::to_value(&stats).map_err(|err| err.into())
36 | }
37 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | nocuous:
7 | runs-on: ubuntu-latest
8 | strategy:
9 | matrix:
10 | version: [1.x, canary]
11 | steps:
12 | - name: clone repository
13 | uses: actions/checkout@v4
14 |
15 | - name: install Rust
16 | uses: dtolnay/rust-toolchain@stable
17 |
18 | - name: cache
19 | uses: Swatinem/rust-cache@v1
20 |
21 | - name: install Deno
22 | uses: denoland/setup-deno@v1
23 | with:
24 | deno-version: ${{ matrix.version }}
25 |
26 | - name: format
27 | run: |
28 | cargo fmt -- --check
29 | deno fmt --check
30 |
31 | - name: lint
32 | run: |
33 | cargo clippy --locked --release --all-features --all-targets
34 | deno lint
35 |
36 | - name: build
37 | run: cargo build
38 |
39 | - name: test
40 | run: deno task test
41 |
42 | - name: check wasm build
43 | run: deno task build --check
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2019 - 2024 Kitson P. Kelly
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | this software and associated documentation files (the "Software"), to deal in
5 | the Software without restriction, including without limitation the rights to
6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7 | the Software, and to permit persons to whom the Software is furnished to do so,
8 | subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19 |
--------------------------------------------------------------------------------
/deno.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@higher-order-testing/nocuous",
3 | "version": "1.1.0",
4 | "exports": {
5 | ".": "./mod.ts",
6 | "./cli": "./cli.ts"
7 | },
8 | "publish": {
9 | "exclude": [
10 | ".github",
11 | ".rustfmt.toml",
12 | ".vscode",
13 | "Cargo.lock",
14 | "Cargo.toml",
15 | "rust-toolchain.toml",
16 | "test.ts",
17 | "src/fixtures"
18 | ]
19 | },
20 | "imports": {
21 | "@cliffy/ansi": "jsr:@cliffy/ansi@1.0.0-rc.7",
22 | "@cliffy/command": "jsr:@cliffy/command@1.0.0-rc.7",
23 | "@cliffy/table": "jsr:@cliffy/table@1.0.0-rc.7",
24 | "@denosaurs/wait": "jsr:@denosaurs/wait@^0.2.2",
25 | "@std/assert": "jsr:@std/assert@^1",
26 | "@std/path": "jsr:@std/path@^1"
27 | },
28 | "fmt": {
29 | "exclude": ["target", "lib"]
30 | },
31 | "lint": {
32 | "exclude": ["target", "lib"]
33 | },
34 | "lock": false,
35 | "tasks": {
36 | "build": "deno run -A jsr:@deno/wasmbuild@0.17.2",
37 | "cli": "deno run --allow-read --allow-net cli.ts",
38 | "test": "deno task test:rust && deno task test:deno",
39 | "test:deno": "deno test --allow-read=..",
40 | "test:rust": "cargo test"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/context.rs:
--------------------------------------------------------------------------------
1 | use crate::stats::StatRecord;
2 |
3 | use deno_ast::view;
4 | use deno_ast::ParsedSource;
5 |
6 | #[derive(Debug)]
7 | pub struct TraversalController(bool);
8 |
9 | impl TraversalController {
10 | fn new() -> Self {
11 | Self(false)
12 | }
13 |
14 | pub fn reset(&mut self) {
15 | self.0 = false;
16 | }
17 |
18 | pub fn should_skip(&mut self) -> bool {
19 | let skip = self.0;
20 | self.reset();
21 | skip
22 | }
23 |
24 | pub fn skip(&mut self) {
25 | self.0 = true;
26 | }
27 | }
28 |
29 | pub struct Context<'view> {
30 | pub parsed_source: ParsedSource,
31 | pub program: view::Program<'view>,
32 | stats: Vec,
33 | pub traversal: TraversalController,
34 | }
35 |
36 | impl<'view> Context<'view> {
37 | pub fn new(
38 | parsed_source: ParsedSource,
39 | program: view::Program<'view>,
40 | ) -> Self {
41 | Self {
42 | parsed_source,
43 | program,
44 | stats: Vec::new(),
45 | traversal: TraversalController::new(),
46 | }
47 | }
48 |
49 | pub fn add_stat(&mut self, stat: StatRecord) {
50 | self.stats.push(stat);
51 | }
52 |
53 | pub fn take(self) -> Vec {
54 | self.stats
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/stats/file_length.rs:
--------------------------------------------------------------------------------
1 | use std::sync::Arc;
2 |
3 | use super::Stat;
4 | use super::StatLevel;
5 | use super::StatRecord;
6 | use crate::analysis::lines_of_code;
7 | use crate::context::Context;
8 |
9 | const CODE: &str = "file-length";
10 | const SHORT_CODE: &str = "L";
11 |
12 | #[derive(Debug)]
13 | pub struct FileLength;
14 |
15 | impl Stat for FileLength {
16 | fn new() -> Arc {
17 | Arc::new(Self)
18 | }
19 |
20 | fn code(&self) -> &'static str {
21 | CODE
22 | }
23 |
24 | fn short_code(&self) -> &'static str {
25 | SHORT_CODE
26 | }
27 |
28 | fn stat<'view>(
29 | &self,
30 | context: &mut Context<'view>,
31 | maybe_threshold: Option,
32 | ) {
33 | let threshold = maybe_threshold.unwrap_or(500);
34 | let code = context.parsed_source.text_info_lazy().text();
35 | let line_count = lines_of_code(code.as_ref());
36 | let score = if line_count >= threshold {
37 | line_count as f64 / threshold as f64
38 | } else {
39 | 0.0
40 | };
41 | context.add_stat(StatRecord {
42 | metric: CODE.to_string(),
43 | metric_short: SHORT_CODE.to_string(),
44 | level: StatLevel::Module,
45 | threshold,
46 | count: 1,
47 | score,
48 | });
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/test.ts:
--------------------------------------------------------------------------------
1 | import { assertEquals } from "@std/assert/equals";
2 | import { join } from "@std/path";
3 | import { asURL, instantiate, stats } from "./mod.ts";
4 |
5 | Deno.test({
6 | name: "generate stats - typescript base",
7 | async fn() {
8 | await instantiate();
9 | const actual = await stats(
10 | new URL("./src/fixtures/main.ts", import.meta.url),
11 | );
12 | assertEquals(actual.size, 4);
13 | },
14 | });
15 |
16 | Deno.test({
17 | name: "generate stats - javascript base",
18 | async fn() {
19 | await instantiate();
20 | const actual = await stats(
21 | new URL("./src/fixtures/index.js", import.meta.url),
22 | );
23 | assertEquals(actual.size, 4);
24 | },
25 | });
26 |
27 | Deno.test({
28 | name: "asURL - relative",
29 | fn() {
30 | const expected = new URL("mod.ts", import.meta.url);
31 | const actual = asURL("./mod.ts");
32 | assertEquals(actual.toString(), expected.toString());
33 | },
34 | });
35 |
36 | Deno.test({
37 | name: "asURL - relative base supplied",
38 | fn() {
39 | const expected = new URL("test/mod.ts", import.meta.url);
40 | const actual = asURL("./mod.ts", join(Deno.cwd(), "test"));
41 | assertEquals(actual.toString(), expected.toString());
42 | },
43 | });
44 |
45 | Deno.test({
46 | name: "asURL - absolute",
47 | fn() {
48 | const expected = new URL("mod.ts", import.meta.url);
49 | const actual = asURL(join(Deno.cwd(), "./mod.ts"));
50 | assertEquals(actual.toString(), expected.toString());
51 | },
52 | });
53 |
--------------------------------------------------------------------------------
/lib/nocuous.generated.d.ts:
--------------------------------------------------------------------------------
1 | // deno-lint-ignore-file
2 | // deno-fmt-ignore-file
3 |
4 | export interface InstantiateResult {
5 | instance: WebAssembly.Instance;
6 | exports: {
7 | stats: typeof stats
8 | };
9 | }
10 |
11 | /** Gets if the Wasm module has been instantiated. */
12 | export function isInstantiated(): boolean;
13 |
14 | /** Options for instantiating a Wasm instance. */
15 | export interface InstantiateOptions {
16 | /** Optional url to the Wasm file to instantiate. */
17 | url?: URL;
18 | /** Callback to decompress the raw Wasm file bytes before instantiating. */
19 | decompress?: (bytes: Uint8Array) => Uint8Array;
20 | }
21 |
22 | /** Instantiates an instance of the Wasm module returning its functions.
23 | * @remarks It is safe to call this multiple times and once successfully
24 | * loaded it will always return a reference to the same object. */
25 | export function instantiate(opts?: InstantiateOptions): Promise;
26 |
27 | /** Instantiates an instance of the Wasm module along with its exports.
28 | * @remarks It is safe to call this multiple times and once successfully
29 | * loaded it will always return a reference to the same object. */
30 | export function instantiateWithInstance(opts?: InstantiateOptions): Promise;
31 |
32 | /**
33 | * @param {any} roots
34 | * @param {Function} load
35 | * @param {Function | undefined} [maybe_resolve]
36 | * @returns {Promise}
37 | */
38 | export function stats(roots: any, load: Function, maybe_resolve?: Function): Promise;
39 |
--------------------------------------------------------------------------------
/src/analysis.rs:
--------------------------------------------------------------------------------
1 | use lazy_static::lazy_static;
2 | use regex::Regex;
3 |
4 | lazy_static! {
5 | static ref RE_BLOCK_END: Regex = Regex::new(r"\*/").unwrap();
6 | static ref RE_BLOCK_START: Regex = Regex::new(r"^\s*/\*").unwrap();
7 | static ref RE_BLOCK_SINGLE: Regex = Regex::new(r"^\s*/\*.*\*/").unwrap();
8 | static ref RE_NON_WHITESPACE: Regex = Regex::new(r"\S").unwrap();
9 | static ref RE_TWOSLASH: Regex = Regex::new(r"^\s*/{2}").unwrap();
10 | }
11 |
12 | /// Given a string, return the count of lines that are considered code in
13 | /// JavaScript or TypeScript, skipping any empty lines or lines that only
14 | /// contain comments.
15 | pub fn lines_of_code(code: &str) -> u32 {
16 | let mut count = 0_u32;
17 | let mut in_comment = false;
18 | for line in code.lines() {
19 | if in_comment {
20 | if RE_BLOCK_END.is_match(line) {
21 | in_comment = false;
22 | }
23 | continue;
24 | }
25 | if RE_TWOSLASH.is_match(line) || RE_BLOCK_SINGLE.is_match(line) {
26 | continue;
27 | }
28 | if RE_BLOCK_START.is_match(line) {
29 | in_comment = true;
30 | continue;
31 | }
32 | if RE_NON_WHITESPACE.is_match(line) {
33 | count += 1;
34 | }
35 | }
36 | count
37 | }
38 |
39 | #[cfg(test)]
40 | mod tests {
41 | use super::*;
42 |
43 | #[test]
44 | fn correct_counts() {
45 | let actual = lines_of_code(
46 | r#"/**
47 | * Some sort of block comment.
48 | */
49 | function a() {
50 |
51 | // a two line comment
52 | const b = "a";
53 |
54 | return b;
55 | }
56 |
57 | /** a single line one. */
58 | const c = 12345;
59 |
60 | // more twoslash
61 |
62 | "#,
63 | );
64 | assert_eq!(actual, 5);
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/stats.rs:
--------------------------------------------------------------------------------
1 | use serde::Serialize;
2 | use std::sync::Arc;
3 |
4 | use crate::context::Context;
5 |
6 | mod anon_inner_length;
7 | mod binary_expression_complexity;
8 | mod class_data_abstraction_coupling;
9 | mod cyclomatic_complexity;
10 | mod file_length;
11 | mod function_length;
12 | mod missing_switch_default;
13 | mod parameter_number;
14 |
15 | #[derive(Debug, Serialize)]
16 | #[serde(rename_all = "camelCase")]
17 | pub enum StatLevel {
18 | Module,
19 | Class,
20 | Function,
21 | Statement,
22 | Item,
23 | }
24 |
25 | impl Default for StatLevel {
26 | fn default() -> Self {
27 | Self::Module
28 | }
29 | }
30 |
31 | #[derive(Debug, Serialize)]
32 | #[serde(rename_all = "camelCase")]
33 | pub struct StatRecord {
34 | pub metric: String,
35 | pub metric_short: String,
36 | pub level: StatLevel,
37 | pub count: u32,
38 | pub threshold: u32,
39 | pub score: f64,
40 | }
41 |
42 | impl Default for StatRecord {
43 | fn default() -> Self {
44 | Self {
45 | metric: "".to_string(),
46 | metric_short: "".to_string(),
47 | level: StatLevel::default(),
48 | count: 0,
49 | threshold: 0,
50 | score: 0.0,
51 | }
52 | }
53 | }
54 |
55 | pub trait Stat: std::fmt::Debug + Send + Sync {
56 | fn new() -> Arc
57 | where
58 | Self: Sized;
59 |
60 | #[allow(dead_code)]
61 | fn code(&self) -> &'static str;
62 |
63 | #[allow(dead_code)]
64 | fn short_code(&self) -> &'static str;
65 |
66 | fn stat<'a>(&self, context: &mut Context<'a>, maybe_threshold: Option);
67 | }
68 |
69 | fn get_stats() -> Vec> {
70 | vec![
71 | anon_inner_length::AnonInnerLength::new(),
72 | binary_expression_complexity::BinaryExpressionComplexity::new(),
73 | class_data_abstraction_coupling::ClassDataAbstractionCoupling::new(),
74 | cyclomatic_complexity::CyclomaticComplexity::new(),
75 | file_length::FileLength::new(),
76 | function_length::FunctionLength::new(),
77 | missing_switch_default::MissingSwitchDefault::new(),
78 | parameter_number::ParameterNumber::new(),
79 | ]
80 | }
81 |
82 | pub fn get_all_stats() -> Vec> {
83 | get_stats()
84 | }
85 |
--------------------------------------------------------------------------------
/src/utils/log.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module
3 | */
4 |
5 | import { colors } from "@cliffy/ansi/colors";
6 |
7 | export class Log {
8 | #log = console.log.bind(console);
9 | #depth = 0;
10 | #segmenter: Intl.Segmenter;
11 |
12 | #colorizeFirstWord(text: string, fn: (s: string) => string) {
13 | const [first, ...rest] = this.#segmenter.segment(text);
14 | return [fn(first.segment), ...rest.map(({ segment }) => segment)].join("");
15 | }
16 |
17 | #indent(value: unknown): string {
18 | const str = typeof value === "string"
19 | ? value
20 | : Deno.inspect(value, { colors: true });
21 | if (this.#depth === 0) {
22 | return str;
23 | }
24 | return str
25 | .split("\n")
26 | .map((line) => `${" ".repeat(this.#depth)}${line}`)
27 | .join("\n");
28 | }
29 |
30 | get depth(): number {
31 | return this.#depth;
32 | }
33 |
34 | constructor(locale = "en") {
35 | this.#segmenter = new Intl.Segmenter(locale, { granularity: "word" });
36 | }
37 |
38 | error(text: string, ...other: unknown[]): this {
39 | this.#log(
40 | this.#indent(this.#colorizeFirstWord(text, colors.brightRed)),
41 | ...other,
42 | );
43 | return this;
44 | }
45 |
46 | group(data?: unknown, ...rest: unknown[]): this {
47 | if (data) {
48 | this.#log(this.#indent(data), ...rest);
49 | }
50 | this.#depth++;
51 | return this;
52 | }
53 |
54 | groupEnd(): this {
55 | this.#depth = this.#depth <= 0 ? 0 : this.#depth - 1;
56 | return this;
57 | }
58 |
59 | kv(key: string, value: unknown): this {
60 | this.#log(this.#indent(`${colors.green(key)}:`), value);
61 | return this;
62 | }
63 |
64 | log(data?: unknown, ...rest: unknown[]): this {
65 | this.#log(this.#indent(data), ...rest);
66 | return this;
67 | }
68 |
69 | light(text: string, ...other: unknown[]): this {
70 | this.#log(this.#indent(colors.dim(text)), ...other);
71 | return this;
72 | }
73 |
74 | step(text: string, ...other: unknown[]): this {
75 | this.#log(
76 | this.#indent(this.#colorizeFirstWord(text, colors.brightGreen)),
77 | ...other,
78 | );
79 | return this;
80 | }
81 |
82 | warn(text: string, ...other: unknown[]): this {
83 | this.#log(
84 | this.#indent(this.#colorizeFirstWord(text, colors.brightYellow)),
85 | ...other,
86 | );
87 | return this;
88 | }
89 | }
90 |
91 | export const log = new Log();
92 |
--------------------------------------------------------------------------------
/src/stats/missing_switch_default.rs:
--------------------------------------------------------------------------------
1 | use deno_ast::swc::ast;
2 | use deno_ast::swc::visit::noop_visit_type;
3 | use deno_ast::swc::visit::Visit;
4 | use deno_ast::swc::visit::VisitWith;
5 | use std::sync::Arc;
6 |
7 | use super::Stat;
8 | use super::StatLevel;
9 | use super::StatRecord;
10 | use crate::context::Context;
11 |
12 | const CODE: &str = "missing-switch-default";
13 | const SHORT_CODE: &str = "MSD";
14 |
15 | #[derive(Debug)]
16 | pub struct MissingSwitchDefault;
17 |
18 | impl Stat for MissingSwitchDefault {
19 | fn new() -> Arc {
20 | Arc::new(Self)
21 | }
22 |
23 | fn code(&self) -> &'static str {
24 | CODE
25 | }
26 |
27 | fn short_code(&self) -> &'static str {
28 | SHORT_CODE
29 | }
30 |
31 | fn stat<'view>(
32 | &self,
33 | context: &mut Context<'view>,
34 | _maybe_threshold: Option,
35 | ) {
36 | let mut collector = MissingSwitchDefaultCollector::new();
37 | context.parsed_source.module().visit_with(&mut collector);
38 | context.add_stat(StatRecord {
39 | metric: CODE.to_string(),
40 | metric_short: SHORT_CODE.to_string(),
41 | level: StatLevel::Statement,
42 | threshold: 1,
43 | count: collector.count,
44 | score: collector.score,
45 | });
46 | }
47 | }
48 |
49 | struct MissingSwitchDefaultCollector {
50 | count: u32,
51 | score: f64,
52 | }
53 |
54 | impl MissingSwitchDefaultCollector {
55 | pub fn new() -> Self {
56 | Self {
57 | count: 0,
58 | score: 0.0,
59 | }
60 | }
61 | }
62 |
63 | impl Visit for MissingSwitchDefaultCollector {
64 | noop_visit_type!();
65 |
66 | fn visit_switch_stmt(&mut self, node: &ast::SwitchStmt) {
67 | self.count += 1;
68 | let has_default = node.cases.iter().any(|case| case.test.is_none());
69 | if !has_default {
70 | self.score += 1.0;
71 | }
72 | }
73 | }
74 |
75 | #[cfg(test)]
76 | mod tests {
77 | use super::*;
78 | use url::Url;
79 |
80 | #[test]
81 | fn counts_missing_default() {
82 | let source = r#"export function foo(value: string) {
83 | switch (value) {
84 | case "foo":
85 | break;
86 | case "bar":
87 | break;
88 | default:
89 | console.log("?");
90 | }
91 |
92 | switch (value) {
93 | case "foo":
94 | break;
95 | }
96 | }"#;
97 |
98 | let parsed_source = deno_ast::parse_module(deno_ast::ParseParams {
99 | specifier: Url::parse("file://test/a.ts").unwrap(),
100 | text: source.into(),
101 | media_type: deno_ast::MediaType::TypeScript,
102 | capture_tokens: true,
103 | scope_analysis: false,
104 | maybe_syntax: None,
105 | })
106 | .unwrap();
107 |
108 | let mut collector = MissingSwitchDefaultCollector::new();
109 | parsed_source.module().visit_with(&mut collector);
110 | assert_eq!(collector.count, 2);
111 | assert_eq!(collector.score, 1.0);
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/commands/stat.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module
3 | */
4 |
5 | import { colors } from "@cliffy/ansi/colors";
6 | import { Command } from "@cliffy/command";
7 | import { type Cell, Row, Table } from "@cliffy/table";
8 | import { wait } from "@denosaurs/wait";
9 | import { common } from "@std/path";
10 |
11 | import { log } from "../utils/log.ts";
12 | import { asURL, instantiate, stats } from "../../mod.ts";
13 |
14 | export default new Command()
15 | .arguments("")
16 | .description("Analyze source outputting code toxicity stats.")
17 | .action(async (_options, source) => {
18 | performance.mark("stats-start");
19 | log.step(`Analyzing code starting at "${source}"...`);
20 | let url: URL;
21 | try {
22 | url = new URL(source);
23 | } catch {
24 | url = asURL(source);
25 | }
26 | const spinner = wait("Analyzing...").start();
27 | await instantiate();
28 | const results = await stats(url);
29 | const measure = performance.measure("stats-start");
30 | spinner.succeed(`Done in ${measure.duration.toFixed(2)}ms.`);
31 | const commonPath = common([...results.keys()]);
32 | const values = new Map<
33 | string,
34 | { metricShort: string; count: number; score: number }
35 | >();
36 | const rows = new Map<
37 | string,
38 | { label: string; total: number; items: Map }
39 | >();
40 | for (const [path, records] of results) {
41 | let total = 0;
42 | const label = path.replace(commonPath, "");
43 | const items = new Map();
44 | for (const { metric, metricShort, count, score } of records) {
45 | if (!values.has(metric)) {
46 | values.set(metric, { metricShort, count: 0, score: 0 });
47 | }
48 | const value = values.get(metric)!;
49 | value.count += count;
50 | value.score += score;
51 | total += score;
52 | items.set(metric, score);
53 | }
54 | rows.set(path, { label, total, items });
55 | }
56 | const statHeader = [...values.values()]
57 | .map(({ metricShort }) => metricShort);
58 | const body: (string | number | Cell | undefined)[][] = [];
59 | for (const { label, total, items } of rows.values()) {
60 | const row: (string | number | Cell | undefined)[] = [
61 | label,
62 | total ? total.toFixed(2) : undefined,
63 | ];
64 | for (const metric of values.keys()) {
65 | const value = items.get(metric);
66 | row.push(value ? value.toFixed(2) : undefined);
67 | }
68 | body.push(row);
69 | }
70 | const counts: (string | number | undefined)[] = [
71 | colors.italic("Counts"),
72 | undefined,
73 | ];
74 | const scores = [colors.bold("Total"), undefined];
75 | for (const { count, score } of values.values()) {
76 | counts.push(count ? count : undefined);
77 | scores.push(score ? score.toFixed(2) : undefined);
78 | }
79 | body.push(counts, scores);
80 | new Table()
81 | .header(Row.from(["Path", "Score", ...statHeader]).border(true))
82 | .body(body)
83 | .render();
84 | });
85 |
--------------------------------------------------------------------------------
/src/resolve.rs:
--------------------------------------------------------------------------------
1 | use anyhow::Result;
2 | #[cfg(not(target_arch = "wasm32"))]
3 | use std::path::Path;
4 | #[cfg(not(target_arch = "wasm32"))]
5 | use std::path::PathBuf;
6 | use url::Url;
7 |
8 | /// Normalize all intermediate components of the path (ie. remove "./" and "../"
9 | /// components).
10 | ///
11 | /// Similar to `fs::canonicalize()` but doesn't resolve symlinks.
12 | ///
13 | /// Taken from Cargo
14 | /// https://github.com/rust-lang/cargo/blob/af307a38c20a753ec60f0ad18be5abed3db3c9ac/src/cargo/util/paths.rs#L60-L85
15 | #[cfg(not(target_arch = "wasm32"))]
16 | fn normalize_path>(path: P) -> PathBuf {
17 | use std::path::Component;
18 |
19 | let mut components = path.as_ref().components().peekable();
20 | let mut ret =
21 | if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
22 | components.next();
23 | PathBuf::from(c.as_os_str())
24 | } else {
25 | PathBuf::new()
26 | };
27 |
28 | for component in components {
29 | match component {
30 | Component::Prefix(..) => unreachable!(),
31 | Component::RootDir => {
32 | ret.push(component.as_os_str());
33 | }
34 | Component::CurDir => {}
35 | Component::ParentDir => {
36 | ret.pop();
37 | }
38 | Component::Normal(c) => {
39 | ret.push(c);
40 | }
41 | }
42 | }
43 | ret
44 | }
45 |
46 | /// Returns true if the input string starts with a sequence of characters
47 | /// that could be a valid URI scheme, like 'https:', 'git+ssh:' or 'data:'.
48 | ///
49 | /// According to RFC 3986 (https://tools.ietf.org/html/rfc3986#section-3.1),
50 | /// a valid scheme has the following format:
51 | /// scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
52 | ///
53 | /// We additionally require the scheme to be at least 2 characters long,
54 | /// because otherwise a windows path like c:/foo would be treated as a URL,
55 | /// while no schemes with a one-letter name actually exist.
56 | fn specifier_has_uri_scheme(specifier: &str) -> bool {
57 | let mut chars = specifier.chars();
58 | let mut len = 0usize;
59 | // The first character must be a letter.
60 | match chars.next() {
61 | Some(c) if c.is_ascii_alphabetic() => len += 1,
62 | _ => return false,
63 | }
64 | // Second and following characters must be either a letter, number,
65 | // plus sign, minus sign, or dot.
66 | loop {
67 | match chars.next() {
68 | Some(c) if c.is_ascii_alphanumeric() || "+-.".contains(c) => len += 1,
69 | Some(':') if len >= 2 => return true,
70 | _ => return false,
71 | }
72 | }
73 | }
74 |
75 | #[cfg(not(target_arch = "wasm32"))]
76 | fn resolve_path(path_str: &str) -> Result {
77 | use anyhow::anyhow;
78 |
79 | let path = std::env::current_dir().unwrap().join(path_str);
80 | let path = normalize_path(&path);
81 | Url::from_file_path(path).map_err(|_| anyhow!("Invalid URL."))
82 | }
83 |
84 | #[cfg(target_arch = "wasm32")]
85 | fn resolve_path(path_str: &str) -> Result {
86 | Url::parse(&format!("file://{}", path_str)).map_err(|err| err.into())
87 | }
88 |
89 | pub(crate) fn resolve_url_or_path(specifier: &str) -> Result {
90 | if specifier_has_uri_scheme(specifier) {
91 | Url::parse(specifier).map_err(|err| err.into())
92 | } else {
93 | resolve_path(specifier)
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/stats/class_data_abstraction_coupling.rs:
--------------------------------------------------------------------------------
1 | use deno_ast::swc::ast;
2 | use deno_ast::swc::visit::noop_visit_type;
3 | use deno_ast::swc::visit::Visit;
4 | use deno_ast::swc::visit::VisitWith;
5 | use std::sync::Arc;
6 |
7 | use super::Stat;
8 | use super::StatLevel;
9 | use super::StatRecord;
10 | use crate::context::Context;
11 |
12 | const CODE: &str = "class-data-abstraction-coupling";
13 | const SHORT_CODE: &str = "CDAC";
14 |
15 | #[derive(Debug)]
16 | pub struct ClassDataAbstractionCoupling;
17 |
18 | impl Stat for ClassDataAbstractionCoupling {
19 | fn new() -> Arc {
20 | Arc::new(Self)
21 | }
22 |
23 | fn code(&self) -> &'static str {
24 | CODE
25 | }
26 |
27 | fn short_code(&self) -> &'static str {
28 | SHORT_CODE
29 | }
30 |
31 | fn stat<'view>(
32 | &self,
33 | context: &mut Context<'view>,
34 | maybe_threshold: Option,
35 | ) {
36 | let threshold = maybe_threshold.unwrap_or(10);
37 | let mut collector = ClassDataAbstractionCouplingCollector::new(threshold);
38 | context.parsed_source.module().visit_with(&mut collector);
39 | context.add_stat(StatRecord {
40 | metric: CODE.to_string(),
41 | metric_short: SHORT_CODE.to_string(),
42 | level: StatLevel::Class,
43 | threshold,
44 | count: collector.count,
45 | score: collector.score,
46 | });
47 | }
48 | }
49 |
50 | struct ClassDataAbstractionCouplingCollector {
51 | count: u32,
52 | score: f64,
53 | threshold: u32,
54 | }
55 |
56 | impl ClassDataAbstractionCouplingCollector {
57 | pub fn new(threshold: u32) -> Self {
58 | Self {
59 | count: 0,
60 | score: 0.0,
61 | threshold,
62 | }
63 | }
64 | }
65 |
66 | impl Visit for ClassDataAbstractionCouplingCollector {
67 | noop_visit_type!();
68 |
69 | fn visit_class(&mut self, node: &ast::Class) {
70 | self.count += 1;
71 | let mut collector = NewExpressionCollector::new();
72 | node.visit_with(&mut collector);
73 | let new_expr_count = collector.count();
74 | if new_expr_count >= self.threshold {
75 | self.score += new_expr_count as f64 / self.threshold as f64;
76 | }
77 | }
78 | }
79 |
80 | struct NewExpressionCollector(u32);
81 |
82 | impl NewExpressionCollector {
83 | pub fn new() -> Self {
84 | Self(0)
85 | }
86 |
87 | pub fn count(&self) -> u32 {
88 | self.0
89 | }
90 | }
91 |
92 | impl Visit for NewExpressionCollector {
93 | noop_visit_type!();
94 |
95 | fn visit_new_expr(&mut self, _node: &ast::NewExpr) {
96 | self.0 += 1;
97 | }
98 | }
99 |
100 | #[cfg(test)]
101 | mod tests {
102 | use super::*;
103 | use url::Url;
104 |
105 | #[test]
106 | fn collector() {
107 | let source = r#"
108 | export class Foo {
109 | foo = "foo";
110 | }
111 |
112 | export class Bar {
113 | foo = new Foo();
114 | foo1 = new Foo();
115 | foo2 = new Foo();
116 | foo3 = new Foo();
117 | foo4 = new Foo();
118 | foo5 = new Foo();
119 | foo6 = new Foo();
120 | foo7 = new Foo();
121 | foo8 = new Foo();
122 | getFoo(): Foo {
123 | return new Foo();
124 | }
125 | }
126 | "#;
127 |
128 | let parsed_source = deno_ast::parse_module(deno_ast::ParseParams {
129 | specifier: Url::parse("file://test/a.ts").unwrap(),
130 | text: source.into(),
131 | media_type: deno_ast::MediaType::TypeScript,
132 | capture_tokens: true,
133 | scope_analysis: false,
134 | maybe_syntax: None,
135 | })
136 | .unwrap();
137 |
138 | let mut collector = ClassDataAbstractionCouplingCollector::new(10);
139 | parsed_source.module().visit_with(&mut collector);
140 | assert_eq!(collector.count, 2);
141 | assert_eq!(collector.score, 1.0);
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/src/stats/binary_expression_complexity.rs:
--------------------------------------------------------------------------------
1 | use deno_ast::swc::ast;
2 | use deno_ast::swc::visit::noop_visit_type;
3 | use deno_ast::swc::visit::Visit;
4 | use deno_ast::swc::visit::VisitWith;
5 | use deno_ast::view;
6 | use std::sync::Arc;
7 |
8 | use super::Stat;
9 | use super::StatLevel;
10 | use super::StatRecord;
11 | use crate::context::Context;
12 | use crate::walker::Traverse;
13 | use crate::walker::Walker;
14 |
15 | const CODE: &str = "binary-expression-complexity";
16 | const SHORT_CODE: &str = "BEC";
17 |
18 | #[derive(Debug)]
19 | pub struct BinaryExpressionComplexity;
20 |
21 | impl Stat for BinaryExpressionComplexity {
22 | fn new() -> Arc {
23 | Arc::new(Self)
24 | }
25 |
26 | fn code(&self) -> &'static str {
27 | CODE
28 | }
29 |
30 | fn short_code(&self) -> &'static str {
31 | SHORT_CODE
32 | }
33 |
34 | fn stat<'view>(
35 | &self,
36 | ctx: &mut Context<'view>,
37 | maybe_threshold: Option,
38 | ) {
39 | let threshold = maybe_threshold.unwrap_or(3);
40 | let mut walker = BinaryExpressionComplexityWalker::new(threshold);
41 | walker.traverse(ctx.program, ctx);
42 | ctx.add_stat(StatRecord {
43 | metric: CODE.to_string(),
44 | metric_short: SHORT_CODE.to_string(),
45 | level: StatLevel::Statement,
46 | threshold,
47 | count: walker.count,
48 | score: walker.score,
49 | });
50 | }
51 | }
52 |
53 | pub struct BinaryExpressionComplexityWalker {
54 | count: u32,
55 | score: f64,
56 | threshold: u32,
57 | }
58 |
59 | impl BinaryExpressionComplexityWalker {
60 | pub fn new(threshold: u32) -> Self {
61 | Self {
62 | count: 0,
63 | score: 0.0,
64 | threshold,
65 | }
66 | }
67 | }
68 |
69 | impl Walker for BinaryExpressionComplexityWalker {
70 | fn bin_expr(&mut self, node: &view::BinExpr, ctx: &mut Context) {
71 | self.count += 1;
72 | let mut counter = BinaryExpressionComplexityCounter::new();
73 | node.inner.visit_children_with(&mut counter);
74 | let complexity = counter.count();
75 | if complexity >= self.threshold {
76 | self.score += complexity as f64 / self.threshold as f64;
77 | }
78 | ctx.traversal.skip();
79 | }
80 | }
81 |
82 | pub struct BinaryExpressionComplexityCounter(u32);
83 |
84 | impl BinaryExpressionComplexityCounter {
85 | pub fn new() -> Self {
86 | Self(1)
87 | }
88 |
89 | pub fn count(&self) -> u32 {
90 | self.0
91 | }
92 | }
93 |
94 | impl Visit for BinaryExpressionComplexityCounter {
95 | noop_visit_type!();
96 |
97 | fn visit_bin_expr(&mut self, node: &ast::BinExpr) {
98 | self.0 += 1;
99 | node.visit_children_with(self);
100 | }
101 | }
102 |
103 | #[cfg(test)]
104 | mod tests {
105 | use super::*;
106 | use url::Url;
107 |
108 | #[test]
109 | fn walker_counts_properly() {
110 | let source = r#"const bar = 1;
111 |
112 | export const foo = bar || "foo";
113 |
114 | if (bar && foo) {
115 | console.log(bar || foo);
116 | }
117 |
118 | if ((bar && foo && true) || "") {
119 | console.log("bar");
120 | }"#;
121 |
122 | let parsed_source = deno_ast::parse_module(deno_ast::ParseParams {
123 | specifier: Url::parse("file://test/a.ts").unwrap(),
124 | text: source.into(),
125 | media_type: deno_ast::MediaType::TypeScript,
126 | capture_tokens: true,
127 | scope_analysis: false,
128 | maybe_syntax: None,
129 | })
130 | .unwrap();
131 |
132 | let mut walker = BinaryExpressionComplexityWalker::new(3);
133 |
134 | parsed_source.with_view(|program| {
135 | let mut ctx = Context::new(parsed_source.clone(), program);
136 | walker.traverse(ctx.program, &mut ctx);
137 | assert_eq!(walker.count, 4);
138 | assert_eq!(walker.score, 1.0);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # nocuous
2 |
3 | 
4 | [](https://jsr.io/@higher-order-testing/nocuous)
5 | [](https://jsr.io/@higher-order-testing/nocuous)
6 |
7 | A static code analysis tool for JavaScript and TypeScript.
8 |
9 | ## Installing the CLI
10 |
11 | If you want to install the CLI, you would need to have Deno
12 | [installed first](https://docs.deno.com/runtime/getting_started/installation/)
13 | and then on the command line, you would want to run the following command:
14 |
15 | ```shell
16 | $ deno install --name nocuous --allow-read --allow-net -f jsr:@higher-order-testing/nocuous/cli
17 | ```
18 |
19 | You can also "pin" to a specific version by using `nocuous@{version}` instead,
20 | for example `jsr:@higher-order-testing/nocuous@1.1.0/cli`.
21 |
22 | The CLI comes with integrated help which can be accessed via the `--help` flag.
23 |
24 | ## Using the API
25 |
26 | If you want to incorporate the API into an application, you need to import it
27 | into your code. For example the following will analyze the Deno std assertion
28 | library and its dependencies resolving with a map of statistics:
29 |
30 | ```ts
31 | import { instantiate, stats } from "jsr:@higher-order-testing/nocuous";
32 |
33 | await instantiate();
34 |
35 | const results = await stats(new URL("https://jsr.io/@std/assert/1.0.6/mod.ts"));
36 |
37 | console.log(results);
38 | ```
39 |
40 | ## Architecture
41 |
42 | The tool uses [swc](https://swc.rs/) as a Rust library to parse code and then
43 | run analysis over the parsed code. It is then compiled to Web Assembly and
44 | exposed as an all-in-one API. Code is loaded via the JavaScript runtime and a
45 | resolver can be provided to allow for custom resolution logic.
46 |
47 | ## Background
48 |
49 | The statistics collected around code toxicity are based directly on Erik
50 | Dörnenburg's article
51 | [How toxic is your code?](https://erik.doernenburg.com/2008/11/how-toxic-is-your-code/).
52 |
53 | The default metrics are based on what is suggested in the article. When applying
54 | to TypeScript/JavaScript there are some adaptation that is required:
55 |
56 | | Metric | Table Label | Description | Default Threshold |
57 | | ------------------------------- | ----------- | ----------------------------------------------------------------------------------------------- | ----------------- |
58 | | File length | L | The number of lines in a file. | 500 |
59 | | Class data abstraction coupling | CDAC | The number of instances of other classes that are "new"ed in a given class. | 10 |
60 | | Anon Inner Length | AIL | Class expressions of arrow functions length in number of lines. | 35 |
61 | | Function Length | FL | The number of statements in a function declaration, function expression, or method declaration. | 30 |
62 | | Parameter Number | P | The number of parameters for a function or method | 6 |
63 | | Cyclomatic Complexity | CC | The cyclomatic complexity for a function or method | 10 |
64 | | Binary Expression Complexity | BEC | How complex a binary expression is (e.g. how many `&&` and ` | |
65 | | Missing Switch Default | MSD | Any `switch` statements that are missing the `default` case. | 1 |
66 |
67 | ---
68 |
69 | Copyright 2019 - 2024 Kitson P. Kelly. MIT License.
70 |
--------------------------------------------------------------------------------
/src/stats/parameter_number.rs:
--------------------------------------------------------------------------------
1 | use deno_ast::swc::ast;
2 | use deno_ast::swc::visit::noop_visit_type;
3 | use deno_ast::swc::visit::Visit;
4 | use deno_ast::swc::visit::VisitWith;
5 | use std::sync::Arc;
6 |
7 | use super::Stat;
8 | use super::StatLevel;
9 | use super::StatRecord;
10 | use crate::context::Context;
11 |
12 | const CODE: &str = "parameter-number";
13 | const SHORT_CODE: &str = "P";
14 |
15 | #[derive(Debug)]
16 | pub struct ParameterNumber;
17 |
18 | impl Stat for ParameterNumber {
19 | fn new() -> Arc {
20 | Arc::new(Self)
21 | }
22 |
23 | fn code(&self) -> &'static str {
24 | CODE
25 | }
26 |
27 | fn short_code(&self) -> &'static str {
28 | SHORT_CODE
29 | }
30 |
31 | fn stat<'view>(
32 | &self,
33 | ctx: &mut Context<'view>,
34 | maybe_threshold: Option,
35 | ) {
36 | let threshold = maybe_threshold.unwrap_or(6);
37 | let mut collector = ParameterNumberCollector::new(threshold);
38 | ctx.parsed_source.module().visit_with(&mut collector);
39 | ctx.add_stat(StatRecord {
40 | metric: CODE.to_string(),
41 | metric_short: SHORT_CODE.to_string(),
42 | level: StatLevel::Function,
43 | threshold,
44 | count: collector.count,
45 | score: collector.score,
46 | });
47 | }
48 | }
49 |
50 | struct ParameterNumberCollector {
51 | count: u32,
52 | score: f64,
53 | threshold: u32,
54 | }
55 |
56 | impl ParameterNumberCollector {
57 | pub fn new(threshold: u32) -> Self {
58 | Self {
59 | count: 0,
60 | score: 0.0,
61 | threshold,
62 | }
63 | }
64 | }
65 |
66 | impl Visit for ParameterNumberCollector {
67 | noop_visit_type!();
68 |
69 | fn visit_arrow_expr(&mut self, node: &ast::ArrowExpr) {
70 | self.count += 1;
71 | let param_count = node.params.len();
72 | if param_count as u32 >= self.threshold {
73 | self.score += param_count as f64 / self.threshold as f64;
74 | }
75 | }
76 |
77 | fn visit_class_method(&mut self, node: &ast::ClassMethod) {
78 | self.count += 1;
79 | let param_count = node.function.params.len();
80 | if param_count as u32 >= self.threshold {
81 | self.score += param_count as f64 / self.threshold as f64;
82 | }
83 | }
84 |
85 | fn visit_fn_decl(&mut self, node: &ast::FnDecl) {
86 | self.count += 1;
87 | let param_count = node.function.params.len();
88 | if param_count as u32 >= self.threshold {
89 | self.score += param_count as f64 / self.threshold as f64;
90 | }
91 | }
92 |
93 | fn visit_fn_expr(&mut self, node: &ast::FnExpr) {
94 | self.count += 1;
95 | let param_count = node.function.params.len();
96 | if param_count as u32 >= self.threshold {
97 | self.score += param_count as f64 / self.threshold as f64;
98 | }
99 | }
100 |
101 | fn visit_method_prop(&mut self, node: &ast::MethodProp) {
102 | self.count += 1;
103 | let param_count = node.function.params.len();
104 | if param_count as u32 >= self.threshold {
105 | self.score += param_count as f64 / self.threshold as f64;
106 | }
107 | }
108 |
109 | fn visit_private_method(&mut self, node: &ast::PrivateMethod) {
110 | self.count += 1;
111 | let param_count = node.function.params.len();
112 | if param_count as u32 >= self.threshold {
113 | self.score += param_count as f64 / self.threshold as f64;
114 | }
115 | }
116 | }
117 |
118 | #[cfg(test)]
119 | mod tests {
120 | use super::*;
121 | use url::Url;
122 |
123 | #[test]
124 | fn collector_works() {
125 | let source = r#"class A {
126 | a(a: string, b: string) {}
127 | #b(a: string, b: string, c: string, d: string, e: string, f: string) {}
128 | }
129 |
130 | function d(a: string, b: string) {}
131 |
132 | function e(a: string, b: string, c: string, d: string, e: string, f: string) {}
133 | "#;
134 |
135 | let parsed_source = deno_ast::parse_module(deno_ast::ParseParams {
136 | specifier: Url::parse("file://test/a.ts").unwrap(),
137 | text: source.into(),
138 | media_type: deno_ast::MediaType::TypeScript,
139 | capture_tokens: true,
140 | scope_analysis: false,
141 | maybe_syntax: None,
142 | })
143 | .unwrap();
144 |
145 | let mut collector = ParameterNumberCollector::new(6);
146 | parsed_source.module().visit_with(&mut collector);
147 | assert_eq!(collector.count, 4);
148 | assert_eq!(collector.score, 2.0);
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/mod.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * A static analysis tool for JavaScript and TypeScript that provides code
3 | * toxicity information.
4 | *
5 | * ### Example
6 | *
7 | * Fetches the `@std/assert` library for Deno and its dependencies and returns
8 | * a map of the code toxicity statistics.
9 | *
10 | * ```ts
11 | * import { instantiate, stats } from "jsr:@higher-order-testing/nocuous";
12 | *
13 | * await instantiate();
14 | *
15 | * const results = await stats(new URL("https://jsr.io/@std/assert/1.0.6/mod.ts"));
16 | *
17 | * console.log(results);
18 | * ```
19 | *
20 | * @module
21 | */
22 |
23 | import { isAbsolute, join, toFileUrl } from "@std/path";
24 | import * as wasm from "./lib/nocuous.generated.js";
25 |
26 | interface InstantiationOptions {
27 | url?: URL;
28 | decompress?: (compressed: Uint8Array) => Uint8Array;
29 | }
30 |
31 | /** The level in a {@linkcode StatRecord} that the statistic pertains to. */
32 | export enum StatLevel {
33 | Module = "module",
34 | Class = "class",
35 | Function = "function",
36 | Statement = "statement",
37 | Item = "item",
38 | }
39 |
40 | /** The interface representing a return value from {@linkcode stats}. */
41 | export interface StatRecord {
42 | /** The name of the metric. */
43 | metric: string;
44 | /** The short name of the metric. This can be used for display in a table
45 | * for example. */
46 | metricShort: string;
47 | /** At what level does the statistic apply to. */
48 | level: StatLevel;
49 | /** The count of the number of items detected in the file/module. */
50 | count: number;
51 | /** The threshold used for determining the score. */
52 | threshold: number;
53 | /** How "toxic" where if any item exceeded the threshold, the score would be
54 | * at least 1, where the value is the statistic divided by the threshold. */
55 | score: number;
56 | }
57 |
58 | /** Options which can be set when calling {@linkcode stats}. */
59 | interface StatsOptions {
60 | /** Override the default load behavior, which uses {@linkcode fetch} to
61 | * retrieve local or remote resources. */
62 | load?: (
63 | specifier: string,
64 | ) => Promise<[content: string | undefined, contentType: string | undefined]>;
65 | /** Override the default resolution behavior, which is a literal resolution
66 | * behavior used by Deno and web browsers. */
67 | resolve?: (specifier: string, referrer: string) => Promise;
68 | }
69 |
70 | async function defaultLoad(
71 | specifier: string,
72 | ): Promise<[content: string | undefined, contentType: string | undefined]> {
73 | const res = await fetch(specifier);
74 | if (res.status === 200) {
75 | const contentType = res.headers.get("content-type") ?? undefined;
76 | return [await res.text(), contentType];
77 | }
78 | return [undefined, undefined];
79 | }
80 |
81 | /** Asynchronously instantiate the Wasm module. This needs to occur before using
82 | * other exported functions. */
83 | export function instantiate(
84 | options?: InstantiationOptions,
85 | ): Promise<{ stats: typeof stats }> {
86 | return wasm.instantiate(options).then(() => ({ stats }));
87 | }
88 |
89 | /** Given a path and an optional base to use for a relative path, return a file
90 | * {@linkcode URL} for the path.
91 | *
92 | * `base` defaults to `Deno.cwd()`. If `base` is not absolute it will throw.
93 | */
94 | export function asURL(path: string, base?: string): URL;
95 | /** Given an array of paths and an optional base to use for relative paths,
96 | * return an array of file {@linkcode URL}s for the paths.
97 | *
98 | * `base` defaults to `Deno.cwd()`. If `base` is not absolute it will throw.
99 | */
100 | export function asURL(paths: string[], base?: string): URL[];
101 | export function asURL(
102 | paths: string | string[],
103 | base = Deno.cwd(),
104 | ): URL | URL[] {
105 | if (!isAbsolute(base)) {
106 | throw new TypeError(`The base of "${base}" must be absolute.`);
107 | }
108 | const inputIsArray = Array.isArray(paths);
109 | paths = Array.isArray(paths) ? paths : [paths];
110 | const urls = paths.map((path) =>
111 | toFileUrl(isAbsolute(path) ? path : join(base, path))
112 | );
113 | return inputIsArray ? urls : urls[0];
114 | }
115 |
116 | /** Given a set of URLs, perform a statistical analysis on the roots and their
117 | * dependencies, resolving with a {@linkcode Map} where the key is the string
118 | * URL of the file and the value is a {@linkcode StatRecord}. */
119 | export function stats(
120 | roots: URL | URL[],
121 | options: StatsOptions = {},
122 | ): Promise