@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 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
@@ -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
- const BUILTIN_LENS_DIR = new URL('../lenses/', import.meta.url).pathname;
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
- const sources = dirs.map(dir => ({ origin: dir, lenses: loadLenses(dir) }));
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: ${dirs.join(', ')}`);
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
- # Provide whatever your --exec command needs. crosscheck itself never
508
- # talks to a model.
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 \\
@@ -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: []
@@ -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 = dir.endsWith('/') ? `${dir}${name}` : `${dir}/${name}`;
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.replace(/\/[^/]*\/?$/, '');
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
- return (globs ?? []).some(g => globToRegExp(g).test(file));
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
- artifactLocation: { uri: finding.file },
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.6.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 test/*.test.mjs"
41
+ "test": "node --test"
41
42
  },
42
43
  "keywords": [
43
44
  "code-review",