specrails-core 6.0.0 → 6.0.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.
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { bindVerificationPlan, pipelineStateDirectory, validateVerificationRequest } from '../pipeline/pipeline-state.js';
5
+ import { assertProposalInScope, withScopeDefault } from './change-scope.js';
5
6
  import { fingerprint } from './durable-store.js';
6
7
  import { child, write } from './graph/artifacts.js';
7
8
  const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
@@ -39,7 +40,10 @@ export function validateProposedChecks(context, raw) {
39
40
  text(check.command, 'command', 4096);
40
41
  if (!Array.isArray(check.args) || check.args.length > 128 || check.args.some(arg => typeof arg !== 'string' || arg.includes('\0') || arg.length > 4096))
41
42
  throw new Error('Invalid verification proposal args');
42
- validateVerificationRequest(context, { kind: 'scoped', commands: [check] });
43
+ if (check.cwd !== undefined && typeof check.cwd !== 'string')
44
+ throw new Error('Invalid verification proposal cwd');
45
+ assertProposalInScope(context, check);
46
+ validateVerificationRequest(context, { kind: 'scoped', commands: [withScopeDefault(context, check)] });
43
47
  if (check.kind === 'command') {
44
48
  if (check.entrypoint !== undefined || check.files !== undefined)
45
49
  throw new Error('Invalid command proposal: harness fields are forbidden');
@@ -123,7 +127,8 @@ export function readVerificationPlan(context) {
123
127
  }
124
128
  export function initializeVerificationPlan(context, host, architect, maxConcurrency = 1) {
125
129
  const previous = readVerificationPlan(context);
126
- const baseline = [['host', host], ['architect', architect]].flatMap(([origin, commands]) => commands.map((command, i) => entry(`${origin}:${command.key ?? `${i}-${fingerprint(normalized(command)).slice(0, 12)}`}`, command.label ?? [command.command, ...command.args].join(' ').slice(0, 256), command, origin)));
130
+ // The key hashes the command as declared, so a scope default never renames a check.
131
+ const baseline = [['host', host], ['architect', architect]].flatMap(([origin, commands]) => commands.map((command, i) => entry(`${origin}:${command.key ?? `${i}-${fingerprint(normalized(command)).slice(0, 12)}`}`, command.label ?? [command.command, ...command.args].join(' ').slice(0, 256), withScopeDefault(context, command), origin)));
127
132
  if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4)
128
133
  throw new Error('Invalid verification concurrency');
129
134
  if (previous && fingerprint(previous.baseline) !== fingerprint(baseline))
@@ -156,7 +161,7 @@ export function addDeveloperChecks(context, proposals) {
156
161
  const developer = new Map(previous.developer.map(item => [item.key, item]));
157
162
  const sources = [];
158
163
  for (const check of checked) {
159
- const command = { repositoryId: check.repositoryId, command: check.command, args: check.args, ...(check.cwd === undefined ? {} : { cwd: check.cwd }), ...(check.timeoutMs === undefined ? {} : { timeoutMs: check.timeoutMs }) };
164
+ const command = withScopeDefault(context, { repositoryId: check.repositoryId, command: check.command, args: check.args, ...(check.cwd === undefined ? {} : { cwd: check.cwd }), ...(check.timeoutMs === undefined ? {} : { timeoutMs: check.timeoutMs }) });
160
165
  const files = check.files?.slice().sort((a, b) => a.path.localeCompare(b.path)).map(file => ({ path: file.path, hash: bytesHash(file.content), byteCount: Buffer.byteLength(file.content) }));
161
166
  const harness = files ? { hash: fingerprint(files), entrypoint: check.entrypoint, sources: files } : undefined;
162
167
  const item = entry('dev:' + check.key, check.label, command, 'developer', harness);
@@ -7,11 +7,18 @@ export interface PipelineSpec {
7
7
  repositoryIds?: string[];
8
8
  acceptanceCriteria?: string[];
9
9
  }
10
+ /**
11
+ * `scope`: checkout-relative directories the repository is confined to — a
12
+ * package registered inside a larger checkout (a monorepo app). Edits outside
13
+ * them are not part of the change, and a command without `cwd` runs in the
14
+ * first one. Absent: the whole checkout is the repository.
15
+ */
10
16
  export interface PipelineRepository {
11
17
  id: string;
12
18
  name: string;
13
19
  path: string;
14
20
  baseSha?: string;
21
+ scope?: string[];
15
22
  }
16
23
  export interface PipelineContext {
17
24
  schemaVersion: 1;
@@ -194,6 +201,10 @@ export interface PreviewFile {
194
201
  contentHash?: string;
195
202
  }
196
203
  export declare function verificationEnvironment(env: NodeJS.ProcessEnv, overrides?: Record<string, string>, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
204
+ /** True when a checkout-relative POSIX path belongs to the repository's scope (always, without a scope). */
205
+ export declare function withinRepositoryScope(repository: Pick<PipelineRepository, 'scope'>, relative: string): boolean;
206
+ /** Where a command without `cwd` runs: the first scope directory, else the checkout root. */
207
+ export declare function repositoryWorkingDirectory(repository: Pick<PipelineRepository, 'path' | 'scope'>): string;
197
208
  export declare function validatePipelineContext(input: unknown): PipelineContext;
198
209
  export declare function pipelineStateDirectory(context: PipelineContext): string;
199
210
  export interface CandidateManifest {
@@ -83,6 +83,58 @@ function safeChild(root, relative) {
83
83
  }
84
84
  return target;
85
85
  }
86
+ /** Case-insensitive filesystems: compare checkout paths the way the OS resolves them. */
87
+ const foldedPaths = process.platform === 'win32' || process.platform === 'darwin';
88
+ function samePathPrefix(file, directory) {
89
+ const [a, b] = foldedPaths ? [file.toLowerCase(), directory.toLowerCase()] : [file, directory];
90
+ return a === b || a.startsWith(b + '/');
91
+ }
92
+ /** Normalized scope: existing, symlink-free directories spelled as on disk. Undefined = whole checkout. */
93
+ function repositoryScope(root, raw) {
94
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > 32)
95
+ fail('Invalid repository scope: expected 1–32 directories');
96
+ const scope = [];
97
+ for (const value of raw) {
98
+ if (typeof value !== 'string' || !value.trim() || value.includes('\0') || value.length > 1024)
99
+ fail('Invalid repository scope directory');
100
+ const relative = value.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
101
+ // The checkout root is the whole repository: no narrower scope applies.
102
+ if (!relative || relative === '.')
103
+ return undefined;
104
+ if (/^[a-z]:/i.test(relative) || relative.startsWith('/') || relative.split('/').some(part => !part || part === '.' || part === '..'))
105
+ fail('Invalid repository scope directory: ' + value);
106
+ // Spell every component as the directory listing does, so git paths match it.
107
+ let current = root;
108
+ const parts = [];
109
+ for (const part of relative.split('/')) {
110
+ let name = part;
111
+ try {
112
+ const names = readdirSync(current);
113
+ if (!names.includes(part))
114
+ name = names.find(entry => entry.toLowerCase() === part.toLowerCase()) ?? part;
115
+ }
116
+ catch { /* the lstat below reports the missing directory */ }
117
+ current = path.join(current, name);
118
+ const stat = lstatSync(current, { throwIfNoEntry: false });
119
+ if (!stat)
120
+ fail('Repository scope directory does not exist: ' + value);
121
+ if (stat.isSymbolicLink() || !stat.isDirectory())
122
+ fail('Repository scope must be a real directory: ' + value);
123
+ parts.push(name);
124
+ }
125
+ scope.push(parts.join('/'));
126
+ }
127
+ const unique = [...new Set(scope)];
128
+ return unique.filter(item => !unique.some(other => other !== item && samePathPrefix(item, other)));
129
+ }
130
+ /** True when a checkout-relative POSIX path belongs to the repository's scope (always, without a scope). */
131
+ export function withinRepositoryScope(repository, relative) {
132
+ return !repository.scope?.length || repository.scope.some(directory => samePathPrefix(relative, directory));
133
+ }
134
+ /** Where a command without `cwd` runs: the first scope directory, else the checkout root. */
135
+ export function repositoryWorkingDirectory(repository) {
136
+ return repository.scope?.length ? path.join(repository.path, ...repository.scope[0].split('/')) : repository.path;
137
+ }
86
138
  function atomicJson(file, value) {
87
139
  mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
88
140
  const temp = file + '.' + randomUUID() + '.tmp';
@@ -106,7 +158,9 @@ export function validatePipelineContext(input) {
106
158
  fail('Invalid repository identity');
107
159
  if (repo.baseSha !== undefined && (typeof repo.baseSha !== 'string' || !/^[a-f0-9]{40,64}$/.test(repo.baseSha)))
108
160
  fail('Invalid repository base SHA');
109
- return { id: repo.id, name: repo.name, path: directory(repo.path), ...(repo.baseSha ? { baseSha: repo.baseSha } : {}) };
161
+ const root = directory(repo.path);
162
+ const scope = repo.scope === undefined ? undefined : repositoryScope(root, repo.scope);
163
+ return { id: repo.id, name: repo.name, path: root, ...(repo.baseSha ? { baseSha: repo.baseSha } : {}), ...(scope ? { scope } : {}) };
110
164
  });
111
165
  if (new Set(repositories.map((repo) => repo.id)).size !== repositories.length || new Set(repositories.map((repo) => repo.path)).size !== repositories.length)
112
166
  fail('Duplicate repository identity or path');
@@ -711,7 +765,7 @@ function validateCommand(context, raw) {
711
765
  const repo = context.repositories.find((item) => item.id === command.repositoryId);
712
766
  if (!repo || typeof command.command !== 'string' || !command.command || command.command.includes('\0') || !Array.isArray(command.args) || !command.args.every((arg) => typeof arg === 'string' && !arg.includes('\0')))
713
767
  fail('Invalid verification command');
714
- const cwd = command.cwd === undefined ? repo.path : directory(path.resolve(repo.path, String(command.cwd)));
768
+ const cwd = command.cwd === undefined ? directory(repositoryWorkingDirectory(repo)) : directory(path.resolve(repo.path, String(command.cwd)));
715
769
  if (!within(repo.path, cwd))
716
770
  fail('Verification cwd escapes selected repository');
717
771
  const env = command.env === undefined ? undefined : object(command.env);
@@ -289,7 +289,7 @@
289
289
  "execution": {
290
290
  "schemaVersion": 1,
291
291
  "contextEnv": "SPECRAILS_EXECUTION_CONTEXT",
292
- "context": "Absolute JSON file: runId, backlogRoot, backlogPath?, artifactRoot, artifactRepositoryId, repositories[{id,name,path,baseSha?}], ownership{git,backlog,worktrees:host|core}, specs[{id,title,description,repositoryIds?,acceptanceCriteria?}]",
292
+ "context": "Absolute JSON file: runId, backlogRoot, backlogPath?, artifactRoot, artifactRepositoryId, repositories[{id,name,path,baseSha?,scope?}], ownership{git,backlog,worktrees:host|core}, specs[{id,title,description,repositoryIds?,acceptanceCriteria?}]. scope (runtime capability repositoryScope) lists the checkout-relative directories a repository is confined to — a package registered inside a larger checkout: commands without cwd run in the first one, model-proposed checks stay inside it, edits outside it are undone after each developer/fixer turn and reviews judge the git change set against the run base.",
293
293
  "runtime": ".specrails/runtime/pipeline.mjs",
294
294
  "operations": [
295
295
  "init",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-core",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "Agent workflow engine for specrails-desktop: provider-independent installer and programmatic agent runtime for Claude Code, Codex, Gemini CLI and Kimi Code",
5
5
  "type": "module",
6
6
  "exports": {