@0xcraft/powershot 1.1.0 → 1.1.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/README.md CHANGED
@@ -211,6 +211,8 @@ The composite action is the shortest setup for GitHub:
211
211
  with:
212
212
  fetch-depth: 0
213
213
 
214
+ - run: npm ci --ignore-scripts
215
+
214
216
  - uses: xcrft/powershot@v1
215
217
  with:
216
218
  verify-only: 'true'
@@ -220,6 +222,18 @@ The composite action is the shortest setup for GitHub:
220
222
  fail-on-findings: 'true'
221
223
  ```
222
224
 
225
+ Install the checked-out project's dependencies before PowerShot so TypeScript can
226
+ resolve its declared ambient types. The example disables lifecycle scripts because
227
+ pull-request code is untrusted; use the equivalent safe install for another package
228
+ manager. In a monorepo, install from the workspace root or add safe install steps for
229
+ the affected package roots.
230
+
231
+ PowerShot discovers `tsconfig.json` and `tsconfig.*.json` along the ancestor chain of
232
+ each changed file. One review can use several independent package projects, skip
233
+ empty solution configs in favour of their leaf configs, and type-check test files
234
+ that a production config excludes. Discovery is change-scoped: unrelated packages
235
+ and configless source trees are not crawled just to build the TypeScript ground.
236
+
223
237
  `@v1` follows compatible `1.x` releases. Pin the action to a full commit SHA in a
224
238
  protected required workflow when immutable dependencies are required.
225
239
 
package/dist/ground.js CHANGED
@@ -1,33 +1,174 @@
1
1
  import { Project, SyntaxKind } from 'ts-morph';
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
3
  import { decode } from './text.js';
4
- import { join, dirname } from 'node:path';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { packFor, parse } from './lang/packs.js';
6
6
  import { insideRepo, isSymlink, repoPath } from './fspolicy.js';
7
7
  const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
8
+ const TS_CONFIG = /^tsconfig(?:\..+)?\.json$/i;
9
+ const MISSING_TYPE_PREFIXES = [
10
+ 'Cannot find global type',
11
+ 'Cannot find global value',
12
+ 'Cannot find module',
13
+ 'Cannot find name',
14
+ 'Cannot find type definition file',
15
+ 'Could not find a declaration file for module',
16
+ ];
17
+ /**
18
+ * A property diagnostic is only exact when the file's ambient types resolved.
19
+ * Missing modules, globals, or declaration files can remove interface
20
+ * augmentations and manufacture downstream errors such as `ImportMeta.url`.
21
+ */
22
+ function hasTypeEnvironmentGap(sf) {
23
+ return sf.getPreEmitDiagnostics().some((diagnostic) => {
24
+ const message = diagnostic.getMessageText();
25
+ const head = typeof message === 'string' ? message : message.getMessageText();
26
+ return MISSING_TYPE_PREFIXES.some((prefix) => head.startsWith(prefix)) || /^File .+ not found/.test(head);
27
+ });
28
+ }
8
29
  export function normalizeName(n) {
9
30
  return n.toLowerCase().replace(/[^a-z0-9]/g, '');
10
31
  }
11
- /**
12
- * Walks up, but never past `stop`.
13
- *
14
- * A tsconfig above the repository is somebody else's, and letting one configure the
15
- * program that reviews this repository lets a parent directory decide what is
16
- * type-checked and where paths resolve to.
17
- */
18
- function findUp(from, name, stop = from) {
19
- let dir = from;
20
- for (;;) {
21
- const p = join(dir, name);
22
- if (existsSync(p))
23
- return p;
24
- if (dir === stop)
25
- return undefined;
32
+ /** A lexical repository containment check for paths that have already been resolved. */
33
+ function isWithin(root, path) {
34
+ const rel = relative(root, path);
35
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
36
+ }
37
+ /** Directories from a changed file up to the repository root, nearest first. */
38
+ function ancestorDirectories(root, absPath) {
39
+ const out = [];
40
+ let dir = dirname(absPath);
41
+ while (isWithin(root, dir)) {
42
+ out.push(dir);
43
+ if (dir === root)
44
+ break;
45
+ const parent = dirname(dir);
46
+ if (parent === dir)
47
+ break;
48
+ dir = parent;
49
+ }
50
+ return out;
51
+ }
52
+ /** Only inspect the directory chain of changed files; never crawl the monorepo. */
53
+ function configsInDirectory(dir, cache) {
54
+ const known = cache.get(dir);
55
+ if (known)
56
+ return known;
57
+ let configs = [];
58
+ try {
59
+ configs = readdirSync(dir, { withFileTypes: true })
60
+ .filter((entry) => entry.isFile() && TS_CONFIG.test(entry.name))
61
+ .map((entry) => join(dir, entry.name))
62
+ .sort();
63
+ }
64
+ catch {
65
+ // An unreadable ancestor contributes no project; the file remains syntax-only.
66
+ }
67
+ cache.set(dir, configs);
68
+ return configs;
69
+ }
70
+ function configuredProject(root, configPath) {
71
+ try {
72
+ const project = new Project({ tsConfigFilePath: configPath });
73
+ const owned = new Set();
74
+ const sourceAncestors = new Set();
75
+ for (const sf of project.getSourceFiles()) {
76
+ const abs = resolve(sf.getFilePath());
77
+ const safe = insideRepo(root, abs);
78
+ if (!safe || repoPath(root, abs).split('/').includes('node_modules'))
79
+ continue;
80
+ owned.add(repoPath(root, abs));
81
+ let dir = dirname(abs);
82
+ while (isWithin(root, dir)) {
83
+ sourceAncestors.add(dir);
84
+ if (dir === root)
85
+ break;
86
+ const parent = dirname(dir);
87
+ if (parent === dir)
88
+ break;
89
+ dir = parent;
90
+ }
91
+ }
92
+ return { configPath, project, owned, sourceAncestors, sourceCount: owned.size };
93
+ }
94
+ catch {
95
+ // A broken candidate beside a usable leaf config must not take down the review.
96
+ return undefined;
97
+ }
98
+ }
99
+ function directoryDepth(root, dir) {
100
+ const rel = repoPath(root, dir);
101
+ return rel === '' ? 0 : rel.split('/').length;
102
+ }
103
+ function sourceAffinity(root, candidate, absPath) {
104
+ let dir = dirname(absPath);
105
+ while (isWithin(root, dir)) {
106
+ if (candidate.sourceAncestors.has(dir))
107
+ return directoryDepth(root, dir);
108
+ if (dir === root)
109
+ break;
26
110
  const parent = dirname(dir);
27
111
  if (parent === dir)
28
- return undefined;
112
+ break;
29
113
  dir = parent;
30
114
  }
115
+ return -1;
116
+ }
117
+ function nameAffinity(root, configPath, absPath) {
118
+ const base = basename(configPath);
119
+ const name = base.slice('tsconfig'.length, -'.json'.length).replace(/^\./, '');
120
+ if (name === '')
121
+ return 0;
122
+ const tokens = new Set(repoPath(root, absPath).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
123
+ return name
124
+ .toLowerCase()
125
+ .split(/[^a-z0-9]+/)
126
+ .filter(Boolean)
127
+ .filter((token) => tokens.has(token)).length;
128
+ }
129
+ /** Prefer the leaf project whose existing sources live closest to this file. */
130
+ function bestProject(root, candidates, absPath) {
131
+ return [...candidates].sort((a, b) => sourceAffinity(root, b, absPath) - sourceAffinity(root, a, absPath) ||
132
+ nameAffinity(root, b.configPath, absPath) - nameAffinity(root, a.configPath, absPath) ||
133
+ a.sourceCount - b.sourceCount ||
134
+ a.configPath.localeCompare(b.configPath))[0];
135
+ }
136
+ function looksLikeTest(path) {
137
+ return /(?:^|[/\\])(?:__tests__|tests?)(?:[/\\]|$)/i.test(path) || /\.(?:test|spec)\.[^.]+$/i.test(path);
138
+ }
139
+ /**
140
+ * Resolve one changed file at the nearest useful project boundary.
141
+ *
142
+ * Solution configs (`files: []`) do not claim a type environment. If a sibling leaf
143
+ * config owns the file it wins; if every leaf excludes the file (common for tests),
144
+ * the closest suitable non-empty leaf supplies compiler options and references.
145
+ * Selecting that local leaf avoids opening a repository-wide parent.
146
+ */
147
+ function projectForFile(root, absPath, directoryCache, projectCache) {
148
+ const rel = repoPath(root, absPath);
149
+ for (const dir of ancestorDirectories(root, absPath)) {
150
+ const configPaths = [...configsInDirectory(dir, directoryCache)].sort((a, b) => nameAffinity(root, b, absPath) - nameAffinity(root, a, absPath) || a.localeCompare(b));
151
+ const projects = [];
152
+ for (const configPath of configPaths) {
153
+ if (!projectCache.has(configPath)) {
154
+ projectCache.set(configPath, configuredProject(root, configPath));
155
+ }
156
+ const project = projectCache.get(configPath);
157
+ if (!project)
158
+ continue;
159
+ // Config names such as `test`, `app`, and `node` are ranked against the file
160
+ // path, so the first owner is the most specific without opening every sibling.
161
+ if (project.owned.has(rel))
162
+ return project;
163
+ projects.push(project);
164
+ }
165
+ const boundaryDepth = directoryDepth(root, dir);
166
+ const leaves = projects.filter((project) => project.sourceCount > 0 &&
167
+ (sourceAffinity(root, project, absPath) > boundaryDepth || looksLikeTest(absPath)));
168
+ if (leaves.length > 0)
169
+ return bestProject(root, leaves, absPath);
170
+ }
171
+ return undefined;
31
172
  }
32
173
  function depsIn(pkgPath) {
33
174
  const deps = new Set();
@@ -70,36 +211,45 @@ function makeDepsFor(root) {
70
211
  };
71
212
  }
72
213
  /**
73
- * Build the oracle once per run: a type-checked project over the working tree,
74
- * a syntax-only project holding the base-ref versions, and a symbol index.
214
+ * Build the oracle once per run: focused type-checked projects for the changed
215
+ * packages, a syntax-only project for unconfigured changes and the base-ref trees,
216
+ * and one deduplicated symbol index over the relevant project closures.
75
217
  */
76
218
  export async function buildGround(root, changed, signal) {
77
- const tsConfigFilePath = findUp(root, 'tsconfig.json');
78
- const typed = Boolean(tsConfigFilePath);
79
- const project = typed
80
- ? new Project({ tsConfigFilePath })
81
- : new Project({ compilerOptions: { allowJs: true, checkJs: false } });
82
- if (!typed)
83
- project.addSourceFilesAtPaths([`${root}/**/*.{ts,tsx,js,jsx,mts,cts}`, `!${root}/**/node_modules/**`]);
84
- // What the tsconfig itself owns. A file added past this point is present for
85
- // reading, but the checker has no program for it — asking one for diagnostics
86
- // throws from inside TypeScript, which took the whole review down with it.
87
- const owned = new Set(project.getSourceFiles().map((f) => repoPath(root, String(f.getFilePath()))));
88
- // A changed file may be new, or excluded from tsconfig — make sure it is present.
89
- // `readable` is the gate for every file that becomes reviewable: the glob above
90
- // follows symlinks, so passing this on the way in is not enough on its own.
219
+ root = resolve(root);
220
+ const directoryCache = new Map();
221
+ const projectCache = new Map();
222
+ const selectedProjects = new Set();
223
+ const assigned = new Map();
224
+ const syntaxProject = new Project({ compilerOptions: { allowJs: true, checkJs: false } });
225
+ // `readable` is the gate for every file that becomes reviewable. A tsconfig glob
226
+ // can follow symlinks, so selected project closures are checked again below too.
91
227
  const readable = (path) => {
92
228
  const abs = insideRepo(root, path);
93
229
  return abs && !isSymlink(abs) ? abs : undefined;
94
230
  };
95
231
  for (const c of changed) {
232
+ if (signal?.aborted)
233
+ break;
96
234
  if (!CODE_EXT.test(c.path))
97
235
  continue;
98
236
  const abs = readable(c.path);
99
- if (!abs)
237
+ if (!abs || !existsSync(abs))
100
238
  continue;
101
- if (!project.getSourceFile(abs) && existsSync(abs))
102
- project.addSourceFileAtPath(abs);
239
+ const configured = projectForFile(root, abs, directoryCache, projectCache);
240
+ if (configured) {
241
+ selectedProjects.add(configured);
242
+ assigned.set(c.path, configured);
243
+ // Tests and tooling files are often excluded from the production build config.
244
+ // Adding one explicitly keeps the leaf project's compiler options and imports.
245
+ if (!configured.project.getSourceFile(abs))
246
+ configured.project.addSourceFileAtPath(abs);
247
+ }
248
+ else if (!syntaxProject.getSourceFile(abs)) {
249
+ // No recursive glob: a configless million-file repository still loads only the
250
+ // files in the review.
251
+ syntaxProject.addSourceFileAtPath(abs);
252
+ }
103
253
  }
104
254
  const beforeProject = new Project({ useInMemoryFileSystem: true });
105
255
  const files = [];
@@ -109,79 +259,78 @@ export async function buildGround(root, changed, signal) {
109
259
  const abs = readable(c.path);
110
260
  if (!abs)
111
261
  continue;
112
- const sf = project.getSourceFile(abs);
262
+ const configured = assigned.get(c.path);
263
+ const sf = configured?.project.getSourceFile(abs) ?? syntaxProject.getSourceFile(abs);
113
264
  if (!sf)
114
265
  continue;
115
266
  const before = c.before === undefined ? undefined : beforeProject.createSourceFile(`/before/${c.path}`, c.before, { overwrite: true });
116
- files.push({ sf, changed: c, before, typed: typed && owned.has(repoPath(root, c.path)) });
267
+ files.push({
268
+ sf,
269
+ changed: c,
270
+ before,
271
+ // A bound program with unresolved ambient types is not an exact type oracle.
272
+ // Keep the file reviewable, but make type-dependent checks explicitly partial.
273
+ typed: configured !== undefined && !hasTypeEnvironmentGap(sf),
274
+ });
117
275
  }
276
+ const projects = [...selectedProjects].map((selected) => selected.project);
277
+ if (syntaxProject.getSourceFiles().length > 0)
278
+ projects.push(syntaxProject);
279
+ const sourceFiles = uniqueSourceFiles(root, files.map((file) => file.sf), projects);
280
+ const configFiles = [...selectedProjects]
281
+ .map((selected) => repoPath(root, selected.configPath))
282
+ .sort();
283
+ const typed = files.some((file) => file.typed);
284
+ const depsFor = makeDepsFor(root);
118
285
  return {
119
286
  root,
120
- project,
287
+ sourceFiles,
288
+ configFiles,
121
289
  beforeProject,
122
290
  changed,
123
291
  files,
124
- symbolIndex: buildSymbolIndex(project, root),
125
- deps: makeDepsFor(root)(join(root, 'x.ts')),
126
- depsFor: makeDepsFor(root),
292
+ symbolIndex: buildSymbolIndex(sourceFiles, root),
293
+ deps: depsFor(join(root, 'x.ts')),
294
+ depsFor,
127
295
  typed,
128
- internalPrefixes: pathAliasPrefixes(project, root),
296
+ internalPrefixes: pathAliasPrefixes(projects),
129
297
  foreign: await parseForeign(root, changed, signal),
130
298
  envManifest: readEnvManifest(root),
131
299
  };
132
300
  }
301
+ /** Prefer changed-file SourceFiles, then add each relevant project source once. */
302
+ function uniqueSourceFiles(root, preferred, projects) {
303
+ const byPath = new Map();
304
+ const add = (sf) => {
305
+ const abs = resolve(sf.getFilePath());
306
+ if (!insideRepo(root, abs))
307
+ return;
308
+ const rel = repoPath(root, abs);
309
+ if (rel.split('/').includes('node_modules') || byPath.has(rel))
310
+ return;
311
+ byPath.set(rel, sf);
312
+ };
313
+ for (const sf of preferred)
314
+ add(sf);
315
+ for (const project of projects)
316
+ for (const sf of project.getSourceFiles())
317
+ add(sf);
318
+ return [...byPath.values()];
319
+ }
133
320
  /**
134
321
  * Prefixes that `compilerOptions.paths` maps back into the repo — `@/*` and friends.
135
322
  * They look like package names but resolve to local files, so treating them as
136
323
  * dependencies would be wrong.
137
324
  */
138
- function pathAliasPrefixes(project, root) {
325
+ function pathAliasPrefixes(projects) {
139
326
  const prefixes = new Set();
140
- for (const pattern of Object.keys(project.getCompilerOptions().paths ?? {})) {
141
- prefixes.add(pattern.replace(/\*$/, ''));
142
- }
143
- // a workspace declares its aliases in each package's tsconfig, and the root config
144
- // often only `extends` a shared base — so the root's paths are not the whole story
145
- for (const file of tsconfigsIn(root)) {
146
- try {
147
- const raw = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1');
148
- const parsed = JSON.parse(raw.replace(/,(\s*[}\]])/g, '$1'));
149
- for (const pattern of Object.keys(parsed?.compilerOptions?.paths ?? {})) {
150
- prefixes.add(String(pattern).replace(/\*$/, ''));
151
- }
152
- }
153
- catch {
154
- // an unreadable or non-standard tsconfig simply contributes no aliases
327
+ for (const project of projects) {
328
+ for (const pattern of Object.keys(project.getCompilerOptions().paths ?? {})) {
329
+ prefixes.add(pattern.replace(/\*$/, ''));
155
330
  }
156
331
  }
157
332
  return [...prefixes];
158
333
  }
159
- /** every tsconfig in the repo, capped so a huge monorepo cannot stall the run */
160
- function tsconfigsIn(root) {
161
- const out = [];
162
- const walk = (dir, depth) => {
163
- if (depth > 4 || out.length > 60)
164
- return;
165
- let entries;
166
- try {
167
- entries = readdirSync(dir, { withFileTypes: true });
168
- }
169
- catch {
170
- return;
171
- }
172
- for (const e of entries) {
173
- if (e.name === 'node_modules' || e.name.startsWith('.'))
174
- continue;
175
- const full = join(dir, e.name);
176
- if (e.isDirectory())
177
- walk(full, depth + 1);
178
- else if (e.name === 'tsconfig.json')
179
- out.push(full);
180
- }
181
- };
182
- walk(root, 0);
183
- return out;
184
- }
185
334
  /**
186
335
  * Changed files the TypeScript project cannot hold. Nine of the checks need only a
187
336
  * parse tree, so a Python or Go file is reviewable the moment its grammar loads —
@@ -235,9 +384,9 @@ async function parseForeign(root, changed, signal) {
235
384
  }
236
385
  return out;
237
386
  }
238
- function buildSymbolIndex(project, root) {
387
+ function buildSymbolIndex(sourceFiles, root) {
239
388
  const index = new Map();
240
- for (const sf of project.getSourceFiles()) {
389
+ for (const sf of sourceFiles) {
241
390
  const path = String(sf.getFilePath());
242
391
  // the project glob follows symlinked directories, so what it loaded is not
243
392
  // proof of where the file is
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { relPath } from '#app/ground.js';
3
- import { insideRepo } from '#app/fspolicy.js';
3
+ import { insideRepo, repoPath } from '#app/fspolicy.js';
4
4
  const MAX_BYTES = 60_000;
5
5
  const MAX_MATCHES = 40;
6
6
  export const TOOLS = [
@@ -58,7 +58,7 @@ function readFile(g, input) {
58
58
  const abs = insideRepo(g.root, path);
59
59
  if (!abs)
60
60
  return 'Refused: that path is outside the repository or is not readable for review.';
61
- const inProject = g.project.getSourceFile(abs);
61
+ const inProject = g.sourceFiles.find((file) => relPath(file, g.root) === repoPath(g.root, abs));
62
62
  const text = inProject ? inProject.getFullText() : readFileSync(abs, 'utf8');
63
63
  const lines = text.split('\n');
64
64
  const start = Math.max(1, Number(input.start ?? 1));
@@ -79,7 +79,7 @@ function grep(g, input) {
79
79
  }
80
80
  const filter = input.path_contains === undefined ? undefined : String(input.path_contains);
81
81
  const hits = [];
82
- for (const sf of g.project.getSourceFiles()) {
82
+ for (const sf of g.sourceFiles) {
83
83
  const abs = sf.getFilePath();
84
84
  // the project glob can pull in a file through a symlinked directory, so the
85
85
  // boundary is re-checked here rather than trusted from how the file got loaded
@@ -98,13 +98,13 @@ function grep(g, input) {
98
98
  return hits.join('\n') + '\n… more matches not shown';
99
99
  }
100
100
  }
101
- return hits.length > 0 ? hits.join('\n') : 'No matches.';
101
+ return hits.length > 0 ? hits.join('\n') : 'No matches in the relevant project files.';
102
102
  }
103
103
  function references(g, input) {
104
104
  const symbol = String(input.symbol ?? '');
105
105
  if (!symbol)
106
106
  return 'No symbol given.';
107
- for (const sf of g.project.getSourceFiles()) {
107
+ for (const sf of g.sourceFiles) {
108
108
  const path = String(sf.getFilePath());
109
109
  if (path.includes('/node_modules/') || !insideRepo(g.root, path))
110
110
  continue;
@@ -117,9 +117,9 @@ function references(g, input) {
117
117
  .map((n) => relPath(n.getSourceFile(), g.root) + ':' + n.getStartLineNumber())
118
118
  .filter((r) => !r.includes('node_modules'));
119
119
  return refs.length === 0
120
- ? symbol + ' is declared in ' + relPath(sf, g.root) + ' and referenced nowhere.'
120
+ ? symbol + ' is declared in ' + relPath(sf, g.root) + ' and referenced nowhere in its project.'
121
121
  : symbol + ' declared in ' + relPath(sf, g.root) + ', referenced at:\n' + [...new Set(refs)].slice(0, MAX_MATCHES).join('\n');
122
122
  }
123
- return 'No declaration named ' + symbol + ' found in the project.';
123
+ return 'No declaration named ' + symbol + ' found in the relevant projects.';
124
124
  }
125
125
  //# sourceMappingURL=tools.js.map
package/dist/review.js CHANGED
@@ -146,8 +146,10 @@ export async function review(opts) {
146
146
  const manifest = opts.manifest;
147
147
  const groundDone = stage('ground');
148
148
  const g = await buildGround(root, changed, opts.signal);
149
- groundDone(g.project.getSourceFiles().length + ' files · ' + g.symbolIndex.size + ' symbols' +
150
- (g.typed ? '' : ' · no tsconfig, phantom-api disabled'));
149
+ groundDone(g.sourceFiles.length + ' files · ' + g.symbolIndex.size + ' symbols' +
150
+ (g.configFiles.length === 0
151
+ ? ' · no usable relevant tsconfig, type checks disabled'
152
+ : ' · ' + g.configFiles.length + ' project(s)' + (g.typed ? '' : ' · type environment incomplete')));
151
153
  const wanted = (v) => {
152
154
  const id = v.id ?? v.name;
153
155
  return opts.checks
package/dist/selftest.js CHANGED
@@ -50,7 +50,7 @@ import { sarif } from './report/sarif.js';
50
50
  import { markdown } from './report/markdown.js';
51
51
  import { wrap } from './report/terminal.js';
52
52
  import { highlight, isJsx } from './report/highlight.js';
53
- import { normalizeName, readEnvManifest, relPath } from './ground.js';
53
+ import { buildGround, normalizeName, readEnvManifest, relPath } from './ground.js';
54
54
  import { incompleteReasons } from './bench.js';
55
55
  import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
56
56
  import { addedLinesFromPatch, createReviewPayload, GitHubPullRequestApi, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
@@ -90,7 +90,7 @@ function ground(files, deps = []) {
90
90
  symbolIndex.set(key, list);
91
91
  }
92
92
  }
93
- return { root, project, beforeProject, changed, files: entries, symbolIndex, deps: new Set(deps), depsFor: () => new Set(deps), typed: false, internalPrefixes: [], foreign: [] };
93
+ return { root, sourceFiles: project.getSourceFiles(), configFiles: [], beforeProject, changed, files: entries, symbolIndex, deps: new Set(deps), depsFor: () => new Set(deps), typed: false, internalPrefixes: [], foreign: [] };
94
94
  }
95
95
  let failures = 0;
96
96
  function check(name, fn) {
@@ -892,7 +892,9 @@ check('self-review publishes machine findings only for a complete verdict', () =
892
892
  const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'review.yml'), 'utf8');
893
893
  assert.equal(workflow.match(/node "\$PSH" review/g)?.length, 1);
894
894
  assert.match(workflow, /name: Check out the untrusted review target[\s\S]+allow-unsafe-pr-checkout: true/);
895
+ assert.match(workflow, /name: Install the target type environment[\s\S]+working-directory: target[\s\S]+npm ci --ignore-scripts/);
895
896
  assert.match(workflow, /steps\.review\.outputs\.status == '0' \|\| steps\.review\.outputs\.status == '1'/);
897
+ assert.match(workflow, /sarif_file: powershot\.sarif\s+checkout_path: target\s+ref: refs\/pull\/\$\{\{ github\.event\.pull_request\.number \}\}\/head\s+sha: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
896
898
  });
897
899
  check('the public action persists judge answers and publishes only a verdict', () => {
898
900
  const action = readFileSync(join(process.cwd(), 'action.yml'), 'utf8');
@@ -912,11 +914,12 @@ check('published CI examples preserve one verdict and its exit status', () => {
912
914
  assert.match(action, /upload-sarif: 'true'/);
913
915
  assert.match(action, /inline-comments: 'true'/);
914
916
  assert.match(action, /runs-on: ubuntu-24\.04/);
915
- assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.0/);
917
+ assert.match(action, /npm ci --ignore-scripts[\s\S]+uses: xcrft\/powershot@v1/);
918
+ assert.match(github, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.1/);
916
919
  assert.equal(github.match(/psh review/g)?.length, 1);
917
920
  assert.match(github, /--report markdown=powershot\.md[\s\S]+--report sarif=powershot\.sarif/);
918
921
  assert.match(github, /\|\| STATUS=\$\?[\s\S]+case "\$STATUS"/);
919
- assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.0/);
922
+ assert.match(gitlab, /npm install --global --ignore-scripts @0xcraft\/powershot@1\.1\.1/);
920
923
  assert.equal(gitlab.match(/psh review/g)?.length, 1);
921
924
  assert.match(gitlab, /--format codequality > gl-code-quality-report\.json \|\| STATUS=\$\?/);
922
925
  assert.match(gitlab, /test "\$STATUS" -le 1 \|\| exit "\$STATUS"/);
@@ -1378,6 +1381,155 @@ check('refuses to run without a tsconfig rather than guessing', () => {
1378
1381
  const g = ground([{ path: 'a.ts', after: 'export const x = totallyUnknownGlobal\n' }]);
1379
1382
  assert.equal(phantomApi.run(g).length, 0);
1380
1383
  });
1384
+ await checkAsync('an unresolved type environment is partial, not a proven phantom API', async () => {
1385
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-phantom-api-types-')));
1386
+ try {
1387
+ mkdirSync(join(dir, 'src'));
1388
+ writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
1389
+ compilerOptions: {
1390
+ target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler', lib: ['ES2023'], strict: true,
1391
+ },
1392
+ include: ['src'],
1393
+ }));
1394
+ const source = "import { fileURLToPath } from 'node:url'\nexport const here = fileURLToPath(import.meta.url)\n";
1395
+ writeFileSync(join(dir, 'src', 'a.ts'), source);
1396
+ const result = await review({
1397
+ root: dir,
1398
+ range: {},
1399
+ changes: [{ path: 'src/a.ts', added: new Set([1, 2]) }],
1400
+ config: loadConfig(dir),
1401
+ verifyOnly: true,
1402
+ checks: ['phantom-api'],
1403
+ });
1404
+ assert.deepEqual(result.findings, []);
1405
+ assert.deepEqual(result.plan?.items()[0]?.missing, ['types']);
1406
+ assert.deepEqual(result.skippedChecks, [{ check: 'phantom-api', missing: 'types' }]);
1407
+ }
1408
+ finally {
1409
+ rmSync(dir, { recursive: true, force: true });
1410
+ }
1411
+ });
1412
+ await checkAsync('phantom-api still proves a property error with a complete type environment', async () => {
1413
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-phantom-api-complete-')));
1414
+ try {
1415
+ mkdirSync(join(dir, 'src'));
1416
+ writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
1417
+ compilerOptions: {
1418
+ target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler', lib: ['ES2023'], strict: true,
1419
+ },
1420
+ include: ['src'],
1421
+ }));
1422
+ const source = "export const value = 'ok'.definitelyMissing()\n";
1423
+ writeFileSync(join(dir, 'src', 'a.ts'), source);
1424
+ const result = await review({
1425
+ root: dir,
1426
+ range: {},
1427
+ changes: [{ path: 'src/a.ts', added: new Set([1]) }],
1428
+ config: loadConfig(dir),
1429
+ verifyOnly: true,
1430
+ checks: ['phantom-api'],
1431
+ });
1432
+ assert.equal(result.findings.length, 1);
1433
+ assert.equal(result.findings[0]?.check, 'phantom-api');
1434
+ assert.equal(result.findings[0]?.confidence, 'proven');
1435
+ assert.deepEqual(result.skippedChecks, []);
1436
+ }
1437
+ finally {
1438
+ rmSync(dir, { recursive: true, force: true });
1439
+ }
1440
+ });
1441
+ await checkAsync('nested solution and leaf configs type both source and excluded test files', async () => {
1442
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-nested-tsconfig-')));
1443
+ try {
1444
+ const web = join(dir, 'packages', 'web');
1445
+ mkdirSync(join(web, 'src'), { recursive: true });
1446
+ writeFileSync(join(web, 'tsconfig.json'), JSON.stringify({
1447
+ files: [],
1448
+ references: [{ path: './tsconfig.app.json' }],
1449
+ }));
1450
+ writeFileSync(join(web, 'tsconfig.app.json'), JSON.stringify({
1451
+ compilerOptions: {
1452
+ target: 'ES2023', module: 'ESNext', moduleResolution: 'Bundler', strict: true,
1453
+ },
1454
+ include: ['src'],
1455
+ exclude: ['src/**/*.test.ts'],
1456
+ }));
1457
+ writeFileSync(join(web, 'src', 'existing.ts'), "export const existing = 'ok'\n");
1458
+ writeFileSync(join(web, 'src', 'app.ts'), "export const app = 'ok'.definitelyMissing()\n");
1459
+ writeFileSync(join(web, 'src', 'app.test.ts'), "export const test = 'ok'.alsoMissing()\n");
1460
+ // An unrelated broken project must never be opened just because it is somewhere
1461
+ // in the same monorepo.
1462
+ mkdirSync(join(dir, 'packages', 'unrelated'), { recursive: true });
1463
+ writeFileSync(join(dir, 'packages', 'unrelated', 'tsconfig.json'), '{broken');
1464
+ const changes = [
1465
+ { path: 'packages/web/src/app.ts', added: new Set([1]) },
1466
+ { path: 'packages/web/src/app.test.ts', added: new Set([1]) },
1467
+ ];
1468
+ const result = await review({
1469
+ root: dir,
1470
+ range: {},
1471
+ changes,
1472
+ config: loadConfig(dir),
1473
+ verifyOnly: true,
1474
+ checks: ['phantom-api'],
1475
+ });
1476
+ assert.equal(result.findings.length, 2);
1477
+ assert.deepEqual(result.skippedChecks, []);
1478
+ assert.deepEqual(result.plan?.items().map((item) => item.missing), [undefined, undefined]);
1479
+ const g = await buildGround(dir, changes);
1480
+ assert.deepEqual(g.configFiles, ['packages/web/tsconfig.app.json']);
1481
+ assert.deepEqual(g.files.map((file) => file.typed), [true, true]);
1482
+ assert.equal(g.sourceFiles.length, 3);
1483
+ }
1484
+ finally {
1485
+ rmSync(dir, { recursive: true, force: true });
1486
+ }
1487
+ });
1488
+ await checkAsync('one review can reuse several independent package projects', async () => {
1489
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-many-projects-')));
1490
+ try {
1491
+ const changes = [];
1492
+ for (const name of ['api', 'web']) {
1493
+ const packageDir = join(dir, 'packages', name);
1494
+ mkdirSync(join(packageDir, 'src'), { recursive: true });
1495
+ writeFileSync(join(packageDir, 'tsconfig.json'), JSON.stringify({
1496
+ compilerOptions: { target: 'ES2023', module: 'ESNext', strict: true },
1497
+ include: ['src'],
1498
+ }));
1499
+ writeFileSync(join(packageDir, 'src', 'one.ts'), 'export const one = 1\n');
1500
+ writeFileSync(join(packageDir, 'src', 'two.ts'), 'export const two = 2\n');
1501
+ changes.push({ path: 'packages/' + name + '/src/one.ts', added: new Set([1]) }, { path: 'packages/' + name + '/src/two.ts', added: new Set([1]) });
1502
+ }
1503
+ const g = await buildGround(dir, changes);
1504
+ assert.deepEqual(g.configFiles, [
1505
+ 'packages/api/tsconfig.json',
1506
+ 'packages/web/tsconfig.json',
1507
+ ]);
1508
+ assert.deepEqual(g.files.map((file) => file.typed), [true, true, true, true]);
1509
+ assert.equal(g.sourceFiles.length, 4);
1510
+ }
1511
+ finally {
1512
+ rmSync(dir, { recursive: true, force: true });
1513
+ }
1514
+ });
1515
+ await checkAsync('a configless repository parses changed files without loading the monorepo', async () => {
1516
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-focused-ground-')));
1517
+ try {
1518
+ mkdirSync(join(dir, 'changed'), { recursive: true });
1519
+ writeFileSync(join(dir, 'changed', 'one.ts'), 'export const one = 1\n');
1520
+ for (let i = 0; i < 100; i++) {
1521
+ const packageDir = join(dir, 'packages', 'package-' + i);
1522
+ mkdirSync(packageDir, { recursive: true });
1523
+ writeFileSync(join(packageDir, 'unrelated.ts'), 'export const unrelated = ' + i + '\n');
1524
+ writeFileSync(join(packageDir, 'tsconfig.json'), '{broken');
1525
+ }
1526
+ const g = await buildGround(dir, [{ path: 'changed/one.ts', added: new Set([1]) }]);
1527
+ assert.equal(g.sourceFiles.length, 1);
1528
+ }
1529
+ finally {
1530
+ rmSync(dir, { recursive: true, force: true });
1531
+ }
1532
+ });
1381
1533
  console.log('\nhelpers');
1382
1534
  check('extractJsonArray survives prose and code fences', () => {
1383
1535
  assert.deepEqual(extractJsonArray('Sure!\n```json\n[{"a":1}]\n```\n'), [{ a: 1 }]);
@@ -47,10 +47,11 @@ export const phantomConfig = {
47
47
  const manifest = g.envManifest;
48
48
  if (!manifest)
49
49
  return [];
50
- // a key used elsewhere in the codebase is a documentation gap, not an invention
50
+ // A key used elsewhere in the relevant project is established configuration,
51
+ // even if the shared manifest is behind. Suppress that lower-signal case.
51
52
  const usedElsewhere = new Set();
52
53
  const changedPaths = new Set(g.changed.map((c) => c.path));
53
- for (const sf of g.project.getSourceFiles()) {
54
+ for (const sf of g.sourceFiles) {
54
55
  const path = relPath(sf, g.root);
55
56
  if (changedPaths.has(path) || path.includes('node_modules'))
56
57
  continue;
@@ -71,17 +72,17 @@ export const phantomConfig = {
71
72
  class: 'verified',
72
73
  check: 'phantom-config',
73
74
  severity: 'medium',
74
- // The manifest is not the process environment. What the oracle settles is
75
- // that nothing in the repository declares or uses this key a deployment
75
+ // The manifest is not the process environment. The exact observation is
76
+ // only that the checked-in manifest does not declare the key; a deployment
76
77
  // can still set it, so "will be undefined" is an inference, not a fact.
77
78
  confidence: 'firm',
78
79
  file: relPath(sf, g.root),
79
80
  line: read.line,
80
81
  span: locate(sf, read.start, read.width).span,
81
- title: 'Reads process.env.' + read.name + ', which is not declared in ' + manifest.file + ' or used anywhere else',
82
+ title: 'Reads process.env.' + read.name + ', which is not declared in ' + manifest.file,
82
83
  evidence: {
83
84
  oracle: manifest.file,
84
- detail: 'the key appears in no manifest entry and in no other source file',
85
+ detail: 'the key appears in no manifest entry',
85
86
  },
86
87
  fix: 'Add ' + read.name + ' to ' + manifest.file + ', or drop the reference if it was invented',
87
88
  });
@@ -59,7 +59,7 @@ engine. The engine does not depend on a workflow provider or terminal layout.
59
59
  |---|---|---|
60
60
  | `src/cli/` | Argument parsing, command dispatch, report publication, exit mapping | Review algorithms |
61
61
  | `src/review.ts` | One review run and its stage orchestration | CLI parsing or presentation |
62
- | `src/ground.ts` | TypeScript project, parse trees, manifests, symbol index | Check selection |
62
+ | `src/ground.ts` | Change-scoped TypeScript projects, parse trees, manifests, symbol index | Check selection |
63
63
  | `src/plan.ts` | File selection and per-file capability accounting | Finding generation |
64
64
  | `src/manifest.ts` | Completion state and the authoritative run record | Rendering |
65
65
  | `src/verifiers/` | Deterministic check implementations | Model calls |
@@ -126,6 +126,20 @@ A run can contain a typed TypeScript file beside a Python file or a TypeScript f
126
126
  excluded from `tsconfig`. Capabilities therefore live on each selected file. A checker
127
127
  available somewhere in the run is not evidence that it inspected every file.
128
128
 
129
+ ### Monorepo grounding follows the change
130
+
131
+ For each changed TypeScript or JavaScript file, grounding inspects only its ancestor
132
+ directories for `tsconfig.json` and `tsconfig.*.json`. The nearest config that owns
133
+ the file wins. Empty solution configs yield to their leaf configs, and an excluded
134
+ test can reuse the closest non-empty leaf project when its type environment resolves.
135
+ Projects and directory listings are cached across files, then their source closures
136
+ are deduplicated for syntax searches and symbol indexing.
137
+
138
+ There is deliberately no repository-wide fallback glob. When no relevant config
139
+ exists, only changed files are parsed and type-dependent capabilities remain absent.
140
+ That keeps a configless or mixed-language monorepo proportional to the review rather
141
+ than to the repository.
142
+
129
143
  ### The manifest owns completion
130
144
 
131
145
  Findings alone cannot distinguish a clean review from an interrupted or unsupported
package/docs/ci.md CHANGED
@@ -49,6 +49,8 @@ jobs:
49
49
  with:
50
50
  fetch-depth: 0
51
51
 
52
+ - run: npm ci --ignore-scripts
53
+
52
54
  - uses: xcrft/powershot@v1
53
55
  with:
54
56
  verify-only: 'true'
@@ -58,6 +60,13 @@ jobs:
58
60
  fail-on-findings: 'true'
59
61
  ```
60
62
 
63
+ Type-aware checks use the checked-out project's declarations, so install its
64
+ dependencies before PowerShot. Lifecycle scripts are disabled here because pull
65
+ request code is untrusted; use the equivalent safe install for another package
66
+ manager. For a monorepo without a root install, repeat the safe install step with the
67
+ relevant package `working-directory`. PowerShot finds nested `tsconfig.json` and
68
+ `tsconfig.*.json` files automatically; the workflow does not need to list projects.
69
+
61
70
  Set `upload-sarif: 'false'` and omit `security-events: write` when GitHub code scanning
62
71
  is unavailable or the workflow should not publish SARIF.
63
72
 
@@ -99,7 +108,7 @@ step inside a larger quality job. The complete example is
99
108
  The core pattern is:
100
109
 
101
110
  ```bash
102
- npm install --global --ignore-scripts @0xcraft/powershot@1.1.0
111
+ npm install --global --ignore-scripts @0xcraft/powershot@1.1.1
103
112
 
104
113
  STATUS=0
105
114
  psh review --verify-only \
@@ -16,6 +16,11 @@ jobs:
16
16
  with:
17
17
  fetch-depth: 0
18
18
 
19
+ # Type-aware checks need the repository's declared types. Keep lifecycle
20
+ # scripts disabled when pull-request code is not trusted. In a monorepo,
21
+ # install at the workspace root or set working-directory to the package root.
22
+ - run: npm ci --ignore-scripts
23
+
19
24
  - uses: xcrft/powershot@v1
20
25
  with:
21
26
  verify-only: 'true'
@@ -19,7 +19,7 @@ jobs:
19
19
  node-version: '24'
20
20
 
21
21
  - name: Install PowerShot
22
- run: npm install --global --ignore-scripts @0xcraft/powershot@1.1.0
22
+ run: npm install --global --ignore-scripts @0xcraft/powershot@1.1.1
23
23
 
24
24
  - name: Review pull request
25
25
  env:
@@ -5,7 +5,7 @@ powershot:
5
5
  variables:
6
6
  GIT_DEPTH: "0"
7
7
  before_script:
8
- - npm install --global --ignore-scripts @0xcraft/powershot@1.1.0
8
+ - npm install --global --ignore-scripts @0xcraft/powershot@1.1.1
9
9
  script:
10
10
  - |
11
11
  STATUS=0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xcraft/powershot",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Oracle-first code review for machine-written code, with deterministic verification and CI-ready reports.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "aglumova <alina.glumova@gmail.com>",