@applesnort/crosscheck 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -0
- package/README.md +5 -0
- package/bin/crosscheck.mjs +38 -7
- package/lenses/architect.md +1 -1
- package/lenses/check.md +1 -1
- package/lenses/security-check.md +1 -1
- package/lenses/taint.md +1 -1
- package/lib/config.mjs +27 -2
- package/lib/lenses.mjs +8 -1
- package/lib/sarif.mjs +4 -1
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to `@applesnort/crosscheck`. Format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning is
|
|
5
|
+
[semver](https://semver.org/), pre-1.0, so a minor bump is where behaviour
|
|
6
|
+
changes land.
|
|
7
|
+
|
|
8
|
+
Entries before 0.7.0 were reconstructed from commit history after the fact, so
|
|
9
|
+
they name what each version did rather than itemising every change in it.
|
|
10
|
+
|
|
11
|
+
**Published to npm: 0.2.0, 0.2.1, 0.2.2, 0.3.0, 0.6.0, 0.7.0.** 0.4.0, 0.5.0,
|
|
12
|
+
0.6.1 and 0.6.2 exist as versions in this repository but were never published,
|
|
13
|
+
so a consumer resolving `@0.6` gets 0.6.0 and none of the fixes after it.
|
|
14
|
+
|
|
15
|
+
## [0.7.1] — unreleased
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **crosscheck could not run on Windows at all.** The packaged lens directory
|
|
20
|
+
was resolved with `new URL(...).pathname`, which is a URL path rather than a
|
|
21
|
+
filesystem one: on Windows it carries a leading slash before the drive letter
|
|
22
|
+
(`/C:/Users/...`). `readdirSync` threw an uncaught `ENOENT` before any command
|
|
23
|
+
that loads the built-in lenses could do anything, so `run` and `lenses` died
|
|
24
|
+
on a raw Node stack trace.
|
|
25
|
+
|
|
26
|
+
The same bug broke **every platform** whenever the install path contained a
|
|
27
|
+
space, because a URL path keeps its percent-escapes: `/Users/me/my source/`
|
|
28
|
+
arrived as `/Users/me/my%20source/` and resolved to nothing.
|
|
29
|
+
|
|
30
|
+
- Config discovery silently stopped working on Windows. `findConfig` walked
|
|
31
|
+
upward using `/` string surgery, so a Windows `cwd` never yielded a parent:
|
|
32
|
+
the search probed the starting directory and gave up. A project's
|
|
33
|
+
`.crosscheckrc.json` was ignored unless you happened to run from the repo
|
|
34
|
+
root — and nothing said so, which is the invisible behaviour this tool rejects
|
|
35
|
+
everywhere else.
|
|
36
|
+
|
|
37
|
+
- Routing lost every directory-anchored glob on Windows. `collectFiles` reported
|
|
38
|
+
paths with the platform separator, and the matcher only understood `/`.
|
|
39
|
+
Extension globs matched anyway (`[^/]*` eats a backslash), so the roster
|
|
40
|
+
filled and no `UNREVIEWED` line appeared while `**/components/**`,
|
|
41
|
+
`**/migrations/**`, `**/views/**`, `**/pages/**` and `**/schema*` quietly
|
|
42
|
+
matched nothing. Coverage narrowed and the run still reported as complete.
|
|
43
|
+
|
|
44
|
+
Paths are now reported with `/` on every platform, which also makes a
|
|
45
|
+
path-mode run agree with the same run under `--diff` — git has always emitted
|
|
46
|
+
`/`.
|
|
47
|
+
|
|
48
|
+
- SARIF `artifactLocation.uri` is a URI reference, so a Windows path was not
|
|
49
|
+
merely ugly: a backslash is not a separator there and GitHub code scanning
|
|
50
|
+
could not map a result back to its file.
|
|
51
|
+
|
|
52
|
+
- `npm test` did not work on Windows. The script globbed `test/*.test.mjs`, and
|
|
53
|
+
neither `cmd.exe` nor PowerShell expands a glob for an external command, so
|
|
54
|
+
node received the pattern as a literal. It now uses Node's own test
|
|
55
|
+
discovery.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- CI runs the suite on Windows and macOS as well as Linux. Nothing above would
|
|
60
|
+
have reached a user with a Windows job in the matrix, and nothing would catch
|
|
61
|
+
a regression without one.
|
|
62
|
+
|
|
63
|
+
- The CLI smoke-test stubs are Node scripts rather than `#!/bin/sh` ones, and no
|
|
64
|
+
longer need an execute bit. The suite could not run on Windows either, which
|
|
65
|
+
is the other half of why none of this was caught.
|
|
66
|
+
|
|
67
|
+
## [0.7.0] — 2026-08-13
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- The `check`, `architect`, `security-check` and `taint` lenses now route
|
|
72
|
+
`.vue` and `.svelte` files. They globbed `.js`/`.ts` and friends but not
|
|
73
|
+
single-file components, so only `ux` matched one — a 2437-line Vue component
|
|
74
|
+
in a real review was read by the usability lens and by nothing else, with no
|
|
75
|
+
correctness, security or architecture pass over the largest file in the
|
|
76
|
+
project.
|
|
77
|
+
|
|
78
|
+
The run still reported as complete, because each lens that skipped the
|
|
79
|
+
component did so via "nothing in scope matches", which reads as a routing
|
|
80
|
+
decision rather than a hole.
|
|
81
|
+
|
|
82
|
+
This is a **behaviour change, not just a fix**: every lens now receives more
|
|
83
|
+
files on any project containing components, so runs there are slower and cost
|
|
84
|
+
more. That is why it is a minor bump rather than a patch.
|
|
85
|
+
|
|
86
|
+
- `test/lenses.test.mjs` asserts the shipped lenses route both extensions. The
|
|
87
|
+
existing routing test only ever checked a plain `.js` file, which is why the
|
|
88
|
+
gap survived.
|
|
89
|
+
|
|
90
|
+
Stylesheets remain deliberately unrouted; the CLI naming an unmatched `.css`
|
|
91
|
+
file in its `UNREVIEWED` line is intended behaviour, not a further instance of
|
|
92
|
+
this bug.
|
|
93
|
+
|
|
94
|
+
## [0.6.2] — unpublished
|
|
95
|
+
|
|
96
|
+
- Install the model CLI in the scaffolded workflow.
|
|
97
|
+
|
|
98
|
+
## [0.6.1] — unpublished
|
|
99
|
+
|
|
100
|
+
- Do not load the same lens directory twice.
|
|
101
|
+
|
|
102
|
+
## [0.6.0] — 2026
|
|
103
|
+
|
|
104
|
+
- Adoption in one command (`crosscheck init`): scaffold config, lenses and
|
|
105
|
+
workflow.
|
|
106
|
+
|
|
107
|
+
## [0.5.0] — unpublished
|
|
108
|
+
|
|
109
|
+
- Cost work: make a standing panel cheap enough to leave switched on.
|
|
110
|
+
|
|
111
|
+
## [0.4.0] — unpublished
|
|
112
|
+
|
|
113
|
+
- Review a change rather than a tree; verify findings before reporting them;
|
|
114
|
+
gate before dispatch.
|
|
115
|
+
- Cancel superseded CI runs instead of queueing them.
|
|
116
|
+
|
|
117
|
+
## [0.3.0] — 2026
|
|
118
|
+
|
|
119
|
+
- Layer lens sources, so adding one lens costs one file instead of forking all
|
|
120
|
+
of them.
|
|
121
|
+
- Document that a project's own lenses stay in that project.
|
|
122
|
+
|
|
123
|
+
## [0.2.2] — 2026
|
|
124
|
+
|
|
125
|
+
- `.crosscheckrc.json`, so a team shares one panel definition instead of a shell
|
|
126
|
+
alias nobody else can see.
|
|
127
|
+
|
|
128
|
+
## [0.2.1] — 2026
|
|
129
|
+
|
|
130
|
+
- Disclose unreviewed files; stop sending lens frontmatter to the model.
|
|
131
|
+
- CLI smoke tests, and two bugs they found.
|
|
132
|
+
|
|
133
|
+
## [0.2.0] — 2026
|
|
134
|
+
|
|
135
|
+
- Publish under the `@applesnort` scope.
|
package/README.md
CHANGED
|
@@ -38,6 +38,11 @@ Set `exec` to whatever runs your model, then:
|
|
|
38
38
|
crosscheck run --diff --dry-run
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
Node 20 or newer, on Linux, macOS or Windows. On Windows, prefer setting `exec`
|
|
42
|
+
in `.crosscheckrc.json` rather than passing it on the command line: `--exec` is
|
|
43
|
+
handed to the shell, and `cmd.exe` does not treat single quotes as quoting, so
|
|
44
|
+
the POSIX spelling `--exec 'claude -p'` arrives with the quotes still attached.
|
|
45
|
+
|
|
41
46
|
## In CI
|
|
42
47
|
|
|
43
48
|
The scaffolded workflow reviews the diff against the base branch, and does two
|
package/bin/crosscheck.mjs
CHANGED
|
@@ -57,7 +57,8 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
57
57
|
import {
|
|
58
58
|
existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync
|
|
59
59
|
} from 'node:fs';
|
|
60
|
-
import { dirname, join, relative, resolve } from 'node:path';
|
|
60
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
61
|
+
import { fileURLToPath } from 'node:url';
|
|
61
62
|
import { formatScore, score } from '../lib/calibrate.mjs';
|
|
62
63
|
import { findConfig, mergeConfig, validateConfig } from '../lib/config.mjs';
|
|
63
64
|
import { parseFrontmatter, resolveLensSet } from '../lib/lenses.mjs';
|
|
@@ -153,7 +154,11 @@ function loadLensMeta(dir) {
|
|
|
153
154
|
return meta;
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
|
|
157
|
+
// fileURLToPath, not `.pathname`: a URL path is not a filesystem path. It keeps
|
|
158
|
+
// percent-escapes, so any install directory containing a space resolves to
|
|
159
|
+
// nothing, and on Windows it carries a leading slash before the drive letter.
|
|
160
|
+
const BUILTIN_LENS_DIR = resolve(
|
|
161
|
+
fileURLToPath(new URL('../lenses/', import.meta.url)));
|
|
157
162
|
|
|
158
163
|
// Lens sources, in increasing precedence: the packaged lenses, then ./lenses or
|
|
159
164
|
// .crosscheck/lenses if present, then anything named by --lenses. Layering
|
|
@@ -183,9 +188,18 @@ function lensSources(option, { includeBuiltin = true } = {}) {
|
|
|
183
188
|
if (dirs.length === 0) {
|
|
184
189
|
fail('no lens directories to load (--no-builtin with no --lenses?)');
|
|
185
190
|
}
|
|
186
|
-
|
|
191
|
+
// The same directory can arrive twice — ./lenses is auto-detected, and running
|
|
192
|
+
// from that project also names it via --lenses. Loading it twice makes every
|
|
193
|
+
// lens in it shadow itself, which reads as a configuration mistake that is not
|
|
194
|
+
// one. Keep the last occurrence, so an explicit --lenses still wins on order.
|
|
195
|
+
const seen = new Map();
|
|
196
|
+
for (const dir of dirs) {
|
|
197
|
+
seen.set(dir, true);
|
|
198
|
+
}
|
|
199
|
+
const uniqueDirs = [...seen.keys()];
|
|
200
|
+
const sources = uniqueDirs.map(dir => ({ origin: dir, lenses: loadLenses(dir) }));
|
|
187
201
|
if (sources.every(source => source.lenses.length === 0)) {
|
|
188
|
-
fail(`no usable lens definitions found in: ${
|
|
202
|
+
fail(`no usable lens definitions found in: ${uniqueDirs.join(', ')}`);
|
|
189
203
|
}
|
|
190
204
|
return sources;
|
|
191
205
|
}
|
|
@@ -220,6 +234,13 @@ function loadLenses(dir) {
|
|
|
220
234
|
return lenses;
|
|
221
235
|
}
|
|
222
236
|
|
|
237
|
+
// Report one separator whatever the platform uses. git diff already speaks `/`,
|
|
238
|
+
// so without this a path-mode run on Windows routed, cached, and reported
|
|
239
|
+
// differently from the same run under --diff. Split on the platform separator
|
|
240
|
+
// rather than replacing every backslash: on POSIX a backslash is a legal
|
|
241
|
+
// character in a filename, and rewriting it would corrupt a real path.
|
|
242
|
+
const toPosix = path => sep === '/' ? path : path.split(sep).join('/');
|
|
243
|
+
|
|
223
244
|
// Expand the positional targets into a concrete file list. Directories are walked;
|
|
224
245
|
// everything is reported relative to cwd so paths in findings match what the user
|
|
225
246
|
// typed.
|
|
@@ -240,7 +261,7 @@ function collectFiles(targets) {
|
|
|
240
261
|
// ../../../ chain is harder to read than the full path, and the model has
|
|
241
262
|
// to resolve whatever we print.
|
|
242
263
|
const rel = relative(process.cwd(), path);
|
|
243
|
-
out.push(!rel || rel.startsWith('..') ? path : rel);
|
|
264
|
+
out.push(toPosix(!rel || rel.startsWith('..') ? path : rel));
|
|
244
265
|
};
|
|
245
266
|
for (const target of targets) {
|
|
246
267
|
if (!existsSync(target)) {
|
|
@@ -502,10 +523,20 @@ jobs:
|
|
|
502
523
|
with:
|
|
503
524
|
node-version: '22.x'
|
|
504
525
|
|
|
526
|
+
# EDIT THIS STEP. crosscheck never talks to a model, so whatever your
|
|
527
|
+
# \`exec\` command names has to exist on the runner. Nothing here is
|
|
528
|
+
# installed for you, and a missing command fails every lens with ENOENT.
|
|
529
|
+
#
|
|
530
|
+
# Claude Code: npm i -g @anthropic-ai/claude-code (exec: claude -p)
|
|
531
|
+
# llm: pipx install llm (exec: llm -m ...)
|
|
532
|
+
# your own: whatever installs it
|
|
533
|
+
- name: Install the model CLI
|
|
534
|
+
run: npm i -g @anthropic-ai/claude-code
|
|
535
|
+
|
|
505
536
|
- name: Review the change
|
|
506
537
|
env:
|
|
507
|
-
#
|
|
508
|
-
#
|
|
538
|
+
# Whatever your exec command needs to authenticate. crosscheck reads no
|
|
539
|
+
# credentials of its own.
|
|
509
540
|
ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
|
|
510
541
|
run: |
|
|
511
542
|
npx @applesnort/crosscheck run \\
|
package/lenses/architect.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: architect
|
|
3
3
|
summary: structure, data shape, coupling, and reversibility of decisions
|
|
4
|
-
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,sql,prisma,graphql}, "**/migrations/**", "**/schema*"]
|
|
4
|
+
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,sql,prisma,graphql,vue,svelte}, "**/migrations/**", "**/schema*"]
|
|
5
5
|
owns: couplings and lock-in that make later change expensive
|
|
6
6
|
not-owns: line-level correctness, style, security categories, usability
|
|
7
7
|
cites: []
|
package/lenses/check.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: check
|
|
3
3
|
summary: correctness — boundaries, absent values, error paths, concurrency
|
|
4
|
-
when: [**/*.{js,mjs,cjs,jsx,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift}]
|
|
4
|
+
when: [**/*.{js,mjs,cjs,jsx,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,vue,svelte}]
|
|
5
5
|
owns: defects that produce wrong behavior at runtime
|
|
6
6
|
not-owns: style, naming, architecture, security categories, usability
|
|
7
7
|
cites: []
|
package/lenses/security-check.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: security-check
|
|
3
3
|
summary: application security — trust boundaries, injection, secrets, exposure
|
|
4
|
-
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,sql}]
|
|
4
|
+
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,sql,vue,svelte}]
|
|
5
5
|
owns: exploitable weaknesses reachable by an untrusted or under-privileged caller
|
|
6
6
|
not-owns: general correctness, architecture preference, usability, styling
|
|
7
7
|
cites: ["OWASP Top 10 (2021)", "OWASP ASVS", "CWE"]
|
package/lenses/taint.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: taint
|
|
3
3
|
summary: data flow from untrusted origin to dangerous operation, and what sanitises it
|
|
4
|
-
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift}]
|
|
4
|
+
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,vue,svelte}]
|
|
5
5
|
owns: untrusted values reaching an operation that interprets them, unsanitised
|
|
6
6
|
not-owns: security policy, authentication design, crypto choice, correctness, architecture, usability
|
|
7
7
|
cites: []
|
package/lib/config.mjs
CHANGED
|
@@ -130,6 +130,31 @@ export function mergeConfig(fileConfig = {}, cliOptions = {}) {
|
|
|
130
130
|
return merged;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Both separators are accepted throughout the walk. A Windows cwd is
|
|
134
|
+
// `C:\repo\src` while a POSIX one is `/repo/src`, and this module stays pure —
|
|
135
|
+
// it must not read the host platform to decide which shape it was handed.
|
|
136
|
+
// Building the walk out of `/` alone meant the parent never resolved on
|
|
137
|
+
// Windows: the search probed the starting directory and stopped, so a project
|
|
138
|
+
// config was silently ignored unless you ran from the repo root.
|
|
139
|
+
const TRAILING_SEPARATORS = /[\\/]+$/;
|
|
140
|
+
|
|
141
|
+
function joinPath(dir, name) {
|
|
142
|
+
const base = dir.replace(TRAILING_SEPARATORS, '');
|
|
143
|
+
const separator = base.includes('\\') && !base.includes('/') ? '\\' : '/';
|
|
144
|
+
return `${base}${separator}${name}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parentOf(dir) {
|
|
148
|
+
const base = dir.replace(TRAILING_SEPARATORS, '');
|
|
149
|
+
const cut = Math.max(base.lastIndexOf('/'), base.lastIndexOf('\\'));
|
|
150
|
+
if (cut < 0) {
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
153
|
+
// The parent of `/a` is the root, not the empty string, or the walk would
|
|
154
|
+
// skip a config sitting at `/`.
|
|
155
|
+
return cut === 0 ? base.slice(0, 1) : base.slice(0, cut);
|
|
156
|
+
}
|
|
157
|
+
|
|
133
158
|
// Walk from `startDir` toward the filesystem root looking for a config file, so
|
|
134
159
|
// running from a subdirectory of a project still picks up its settings.
|
|
135
160
|
// `readFile` and `exists` are injected to keep this testable without a disk.
|
|
@@ -142,7 +167,7 @@ export function findConfig(startDir, { exists, isRoot = null } = {}) {
|
|
|
142
167
|
while (dir && !seen.has(dir)) {
|
|
143
168
|
seen.add(dir);
|
|
144
169
|
for (const name of CONFIG_FILENAMES) {
|
|
145
|
-
const candidate =
|
|
170
|
+
const candidate = joinPath(dir, name);
|
|
146
171
|
if (exists(candidate)) {
|
|
147
172
|
return candidate;
|
|
148
173
|
}
|
|
@@ -150,7 +175,7 @@ export function findConfig(startDir, { exists, isRoot = null } = {}) {
|
|
|
150
175
|
if (isRoot?.(dir)) {
|
|
151
176
|
return null;
|
|
152
177
|
}
|
|
153
|
-
const parent = dir
|
|
178
|
+
const parent = parentOf(dir);
|
|
154
179
|
if (parent === dir || parent === '') {
|
|
155
180
|
return null;
|
|
156
181
|
}
|
package/lib/lenses.mjs
CHANGED
|
@@ -150,8 +150,15 @@ export function globToRegExp(glob) {
|
|
|
150
150
|
return new RegExp(`^${out}$`);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// Routing globs are written with `/`, and a Windows path arrives with `\`.
|
|
154
|
+
// Extension globs matched either way — `[^/]*` eats a backslash — so a Windows
|
|
155
|
+
// run filled its roster and announced no coverage hole while every
|
|
156
|
+
// directory-anchored glob quietly stopped matching. Normalising here is safe
|
|
157
|
+
// even where a backslash is a legal filename character: the worst case is that
|
|
158
|
+
// one extra lens reads one extra file.
|
|
153
159
|
export function matchesAny(file, globs) {
|
|
154
|
-
|
|
160
|
+
const path = String(file ?? '').replace(/\\/g, '/');
|
|
161
|
+
return (globs ?? []).some(g => globToRegExp(g).test(path));
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
// lenses: [{name, when: [globs], ...}], files: [paths in scope]
|
package/lib/sarif.mjs
CHANGED
|
@@ -80,7 +80,10 @@ function resultFor(finding) {
|
|
|
80
80
|
message: { text },
|
|
81
81
|
locations: [{
|
|
82
82
|
physicalLocation: {
|
|
83
|
-
|
|
83
|
+
// A URI reference, not a filesystem path: a backslash is not a
|
|
84
|
+
// separator here, and a consumer will not map the result back to a
|
|
85
|
+
// file.
|
|
86
|
+
artifactLocation: { uri: finding.file.replace(/\\/g, '/') },
|
|
84
87
|
region: { startLine: finding.line }
|
|
85
88
|
}
|
|
86
89
|
}],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applesnort/crosscheck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Run independent review lenses in parallel and merge their findings into one deduped, consensus-ranked report \u2014 with SARIF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Joel Mangin",
|
|
@@ -34,10 +34,11 @@
|
|
|
34
34
|
"foreman.md",
|
|
35
35
|
"PROVENANCE.md",
|
|
36
36
|
"README.md",
|
|
37
|
+
"CHANGELOG.md",
|
|
37
38
|
"LICENSE"
|
|
38
39
|
],
|
|
39
40
|
"scripts": {
|
|
40
|
-
"test": "node --test
|
|
41
|
+
"test": "node --test"
|
|
41
42
|
},
|
|
42
43
|
"keywords": [
|
|
43
44
|
"code-review",
|