peaks-cli 1.0.16 → 1.0.18

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.
Files changed (37) hide show
  1. package/bin/peaks.js +0 -0
  2. package/dist/src/cli/commands/request-commands.js +109 -3
  3. package/dist/src/cli/commands/scan-commands.d.ts +3 -0
  4. package/dist/src/cli/commands/scan-commands.js +194 -0
  5. package/dist/src/cli/commands/workspace-commands.d.ts +3 -0
  6. package/dist/src/cli/commands/workspace-commands.js +32 -0
  7. package/dist/src/cli/program.js +4 -0
  8. package/dist/src/services/artifacts/artifact-lint-service.d.ts +23 -0
  9. package/dist/src/services/artifacts/artifact-lint-service.js +80 -0
  10. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +28 -0
  11. package/dist/src/services/artifacts/artifact-prerequisites.js +77 -0
  12. package/dist/src/services/artifacts/repair-cycle-service.d.ts +23 -0
  13. package/dist/src/services/artifacts/repair-cycle-service.js +52 -0
  14. package/dist/src/services/artifacts/request-artifact-service.d.ts +14 -0
  15. package/dist/src/services/artifacts/request-artifact-service.js +73 -21
  16. package/dist/src/services/scan/acceptance-coverage-service.d.ts +42 -0
  17. package/dist/src/services/scan/acceptance-coverage-service.js +135 -0
  18. package/dist/src/services/scan/archetype-service.d.ts +5 -0
  19. package/dist/src/services/scan/archetype-service.js +253 -0
  20. package/dist/src/services/scan/diff-scope-service.d.ts +40 -0
  21. package/dist/src/services/scan/diff-scope-service.js +198 -0
  22. package/dist/src/services/scan/existing-system-service.d.ts +7 -0
  23. package/dist/src/services/scan/existing-system-service.js +300 -0
  24. package/dist/src/services/scan/scan-types.d.ts +59 -0
  25. package/dist/src/services/scan/scan-types.js +1 -0
  26. package/dist/src/services/scan/type-sanity-service.d.ts +23 -0
  27. package/dist/src/services/scan/type-sanity-service.js +108 -0
  28. package/dist/src/services/workspace/workspace-service.d.ts +16 -0
  29. package/dist/src/services/workspace/workspace-service.js +66 -0
  30. package/dist/src/shared/version.d.ts +1 -1
  31. package/dist/src/shared/version.js +1 -1
  32. package/package.json +1 -1
  33. package/skills/peaks-qa/SKILL.md +42 -0
  34. package/skills/peaks-rd/SKILL.md +65 -2
  35. package/skills/peaks-solo/SKILL.md +389 -263
  36. package/skills/peaks-solo/references/existing-system-extraction.md +78 -0
  37. package/skills/peaks-solo/results.tsv +0 -1
@@ -0,0 +1,253 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isDirectory, pathExists, readText } from '../../shared/fs.js';
4
+ const BACKEND_DEP_NAMES = [
5
+ 'express',
6
+ 'koa',
7
+ 'fastify',
8
+ '@nestjs/core',
9
+ '@nestjs/common',
10
+ 'hapi',
11
+ '@hapi/hapi',
12
+ 'restify',
13
+ 'next' // treated separately for API routes
14
+ ];
15
+ const BACKEND_DIR_CANDIDATES = ['server', 'backend', 'api', 'apps/server', 'apps/api', 'packages/server', 'packages/api'];
16
+ const MONOREPO_CONFIG_FILES = ['pnpm-workspace.yaml', 'lerna.json', 'turbo.json', 'nx.json', 'rush.json'];
17
+ const SWAGGER_CANDIDATE_PATHS = [
18
+ 'swagger.json',
19
+ 'swagger.yaml',
20
+ 'openapi.json',
21
+ 'openapi.yaml',
22
+ 'openapi.yml',
23
+ 'docs/swagger.json',
24
+ 'docs/openapi.json',
25
+ 'docs/openapi.yaml'
26
+ ];
27
+ async function readPackageJsonDeps(projectRoot) {
28
+ const pkgPath = join(projectRoot, 'package.json');
29
+ if (!(await pathExists(pkgPath))) {
30
+ return { exists: false, deps: {} };
31
+ }
32
+ try {
33
+ const raw = await readText(pkgPath);
34
+ const parsed = JSON.parse(raw);
35
+ const deps = {
36
+ ...(parsed.dependencies ?? {}),
37
+ ...(parsed.devDependencies ?? {}),
38
+ ...(parsed.peerDependencies ?? {}),
39
+ ...(parsed.optionalDependencies ?? {})
40
+ };
41
+ return { exists: true, deps };
42
+ }
43
+ catch {
44
+ return { exists: true, deps: {} };
45
+ }
46
+ }
47
+ async function detectBackendFrameworks(deps) {
48
+ return BACKEND_DEP_NAMES.filter((name) => name !== 'next' && Object.prototype.hasOwnProperty.call(deps, name));
49
+ }
50
+ async function detectNextApiRoutes(projectRoot, hasNext) {
51
+ if (!hasNext) {
52
+ return false;
53
+ }
54
+ const candidates = ['pages/api', 'src/pages/api', 'app/api', 'src/app/api'];
55
+ for (const candidate of candidates) {
56
+ if (await isDirectory(join(projectRoot, candidate))) {
57
+ return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+ async function detectBackendDirs(projectRoot) {
63
+ const found = [];
64
+ for (const candidate of BACKEND_DIR_CANDIDATES) {
65
+ if (await isDirectory(join(projectRoot, candidate))) {
66
+ found.push(candidate);
67
+ }
68
+ }
69
+ return found;
70
+ }
71
+ async function detectSwagger(projectRoot) {
72
+ const found = [];
73
+ for (const candidate of SWAGGER_CANDIDATE_PATHS) {
74
+ if (await pathExists(join(projectRoot, candidate))) {
75
+ found.push(candidate);
76
+ }
77
+ }
78
+ const protoDir = join(projectRoot, 'proto');
79
+ if (await isDirectory(protoDir)) {
80
+ found.push('proto/');
81
+ }
82
+ return found;
83
+ }
84
+ async function detectMonorepoConfigs(projectRoot) {
85
+ const found = [];
86
+ for (const file of MONOREPO_CONFIG_FILES) {
87
+ if (await pathExists(join(projectRoot, file))) {
88
+ found.push(file);
89
+ }
90
+ }
91
+ return found;
92
+ }
93
+ async function countSrcFiles(projectRoot, max = 500) {
94
+ const srcDir = join(projectRoot, 'src');
95
+ if (!(await isDirectory(srcDir))) {
96
+ return 0;
97
+ }
98
+ let count = 0;
99
+ const queue = [srcDir];
100
+ while (queue.length > 0 && count < max) {
101
+ const current = queue.shift();
102
+ if (current === undefined)
103
+ break;
104
+ let entries;
105
+ try {
106
+ entries = await readdir(current, { withFileTypes: true });
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ for (const entry of entries) {
112
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
113
+ continue;
114
+ const full = join(current, entry.name);
115
+ if (entry.isDirectory()) {
116
+ queue.push(full);
117
+ }
118
+ else if (/\.(tsx?|jsx?|vue|svelte)$/.test(entry.name)) {
119
+ count += 1;
120
+ if (count >= max)
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ return count;
126
+ }
127
+ async function lockfileAgeDays(projectRoot) {
128
+ const candidates = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lockb'];
129
+ for (const candidate of candidates) {
130
+ const full = join(projectRoot, candidate);
131
+ if (await pathExists(full)) {
132
+ try {
133
+ const stats = await stat(full);
134
+ const ageMs = Date.now() - stats.mtimeMs;
135
+ return Math.floor(ageMs / (1000 * 60 * 60 * 24));
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+ function decideArchetype(detected) {
145
+ const signals = [];
146
+ const hasBackend = detected.hasBackendFramework || detected.hasNextApiRoutes || detected.backendDirsPresent.length > 0;
147
+ signals.push({
148
+ name: 'backend-presence',
149
+ matched: hasBackend,
150
+ detail: hasBackend
151
+ ? [
152
+ detected.backendFrameworks.length > 0 ? `framework: ${detected.backendFrameworks.join(', ')}` : null,
153
+ detected.hasNextApiRoutes ? 'next-api-routes' : null,
154
+ detected.backendDirsPresent.length > 0 ? `dirs: ${detected.backendDirsPresent.join(', ')}` : null
155
+ ]
156
+ .filter(Boolean)
157
+ .join('; ')
158
+ : 'no backend framework, no next API routes, no backend dirs'
159
+ });
160
+ signals.push({
161
+ name: 'swagger-or-proto',
162
+ matched: detected.hasSwaggerOrProto,
163
+ detail: detected.hasSwaggerOrProto ? detected.swaggerPaths.join(', ') : 'no swagger/openapi/proto'
164
+ });
165
+ signals.push({
166
+ name: 'monorepo-config',
167
+ matched: detected.hasMonorepoConfig,
168
+ detail: detected.hasMonorepoConfig ? detected.monorepoConfigs.join(', ') : 'no monorepo config'
169
+ });
170
+ signals.push({
171
+ name: 'src-size',
172
+ matched: detected.srcFileCount >= 20,
173
+ detail: `${detected.srcFileCount} source files in src/`
174
+ });
175
+ signals.push({
176
+ name: 'lockfile-age',
177
+ matched: detected.lockfileAgeDays !== null && detected.lockfileAgeDays > 180,
178
+ detail: detected.lockfileAgeDays === null ? 'no lockfile' : `${detected.lockfileAgeDays} days`
179
+ });
180
+ if (!detected.hasPackageJson) {
181
+ return { archetype: 'unknown', confidence: 'low', signals };
182
+ }
183
+ if (detected.hasMonorepoConfig && !hasBackend) {
184
+ return { archetype: 'frontend-monorepo', confidence: 'high', signals };
185
+ }
186
+ if (hasBackend && detected.srcFileCount >= 20) {
187
+ return { archetype: 'legacy-fullstack', confidence: 'high', signals };
188
+ }
189
+ const greenfieldSignals = [
190
+ detected.srcFileCount < 20,
191
+ detected.lockfileAgeDays === null || detected.lockfileAgeDays <= 30,
192
+ !detected.hasSwaggerOrProto
193
+ ];
194
+ const greenfieldSignalCount = greenfieldSignals.filter(Boolean).length;
195
+ // Greenfield must show both a small src AND a fresh/missing lockfile — otherwise an empty-src legacy stub still looks like greenfield.
196
+ if (!hasBackend && greenfieldSignals[0] === true && greenfieldSignals[1] === true) {
197
+ return { archetype: 'greenfield', confidence: greenfieldSignalCount === 3 ? 'high' : 'medium', signals };
198
+ }
199
+ const legacySignalCount = [
200
+ !hasBackend,
201
+ !detected.hasSwaggerOrProto,
202
+ (detected.lockfileAgeDays !== null && detected.lockfileAgeDays > 180) || detected.srcFileCount >= 20
203
+ ].filter(Boolean).length;
204
+ if (!hasBackend && legacySignalCount >= 2) {
205
+ return { archetype: 'legacy-frontend', confidence: legacySignalCount === 3 ? 'high' : 'medium', signals };
206
+ }
207
+ if (hasBackend) {
208
+ return { archetype: 'legacy-fullstack', confidence: 'medium', signals };
209
+ }
210
+ return { archetype: 'unknown', confidence: 'low', signals };
211
+ }
212
+ function decideFrontendOnly(report) {
213
+ if (report.archetype === 'legacy-frontend' || report.archetype === 'frontend-monorepo') {
214
+ return { frontendOnly: true, reason: `archetype=${report.archetype}` };
215
+ }
216
+ const noBackend = !report.detected.hasBackendFramework && !report.detected.hasNextApiRoutes && report.detected.backendDirsPresent.length === 0;
217
+ if (noBackend && !report.detected.hasSwaggerOrProto) {
218
+ return { frontendOnly: true, reason: 'no-backend-no-swagger' };
219
+ }
220
+ if (report.detected.hasBackendFramework || report.detected.hasNextApiRoutes || report.detected.backendDirsPresent.length > 0) {
221
+ return { frontendOnly: false, reason: 'backend-detected' };
222
+ }
223
+ return { frontendOnly: false, reason: 'swagger-or-proto-present' };
224
+ }
225
+ export async function scanArchetype(options) {
226
+ const { projectRoot } = options;
227
+ const { exists: hasPackageJson, deps } = await readPackageJsonDeps(projectRoot);
228
+ const backendFrameworks = await detectBackendFrameworks(deps);
229
+ const hasNext = Object.prototype.hasOwnProperty.call(deps, 'next');
230
+ const hasNextApiRoutes = await detectNextApiRoutes(projectRoot, hasNext);
231
+ const backendDirsPresent = await detectBackendDirs(projectRoot);
232
+ const swaggerPaths = await detectSwagger(projectRoot);
233
+ const monorepoConfigs = await detectMonorepoConfigs(projectRoot);
234
+ const srcFileCount = await countSrcFiles(projectRoot);
235
+ const ageDays = await lockfileAgeDays(projectRoot);
236
+ const detected = {
237
+ hasPackageJson,
238
+ hasBackendFramework: backendFrameworks.length > 0,
239
+ backendFrameworks,
240
+ hasSwaggerOrProto: swaggerPaths.length > 0,
241
+ swaggerPaths,
242
+ hasMonorepoConfig: monorepoConfigs.length > 0,
243
+ monorepoConfigs,
244
+ hasNextApiRoutes,
245
+ srcFileCount,
246
+ backendDirsPresent,
247
+ lockfileAgeDays: ageDays
248
+ };
249
+ const { archetype, confidence, signals } = decideArchetype(detected);
250
+ const base = { archetype, confidence, signals, detected };
251
+ const { frontendOnly, reason } = decideFrontendOnly(base);
252
+ return { ...base, frontendOnly, frontendOnlyReason: reason };
253
+ }
@@ -0,0 +1,40 @@
1
+ export type ScopePattern = {
2
+ raw: string;
3
+ regex: RegExp;
4
+ line: number;
5
+ };
6
+ export type FileClassification = 'in-scope' | 'out-of-scope-violation' | 'unclassified' | 'auto-allowed';
7
+ export type ClassifiedFile = {
8
+ path: string;
9
+ classification: FileClassification;
10
+ matchedPattern?: string;
11
+ reason: string;
12
+ };
13
+ export type DiffScopeReport = {
14
+ ok: boolean;
15
+ rdArtifactPath: string;
16
+ inScopePatterns: ScopePattern[];
17
+ outOfScopePatterns: ScopePattern[];
18
+ changedFiles: ClassifiedFile[];
19
+ violations: ClassifiedFile[];
20
+ unclassified: ClassifiedFile[];
21
+ gitAvailable: boolean;
22
+ patternsDeclared: boolean;
23
+ };
24
+ export type DiffScopeError = {
25
+ kind: 'rd-not-found';
26
+ };
27
+ export type DiffScopeOptions = {
28
+ projectRoot: string;
29
+ requestId: string;
30
+ sessionId?: string;
31
+ baseRef?: string;
32
+ };
33
+ /**
34
+ * Convert a simple glob pattern to a regex.
35
+ * Supports `**` (any path including separators), `*` (one path segment), `?` (single char).
36
+ * Leading `/` is removed; matching is relative to project root.
37
+ */
38
+ export declare function globToRegex(pattern: string): RegExp;
39
+ export declare function getDiffVsScope(options: DiffScopeOptions): Promise<DiffScopeReport | DiffScopeError>;
40
+ export declare function isDiffScopeError(value: DiffScopeReport | DiffScopeError): value is DiffScopeError;
@@ -0,0 +1,198 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { showRequestArtifact } from '../artifacts/request-artifact-service.js';
3
+ const RED_LINE_HEADER = /^##\s+Red-line scope\s*$/;
4
+ const IN_SCOPE_SUBHEADER = /^(?:###?\s+)?(?:in[- ]scope|scope|allowed):\s*$/i;
5
+ const OUT_OF_SCOPE_SUBHEADER = /^(?:###?\s+)?(?:out[- ]of[- ]scope|forbidden|excluded|not in scope|do not touch):\s*$/i;
6
+ const OUT_OF_SCOPE_INLINE = /\b(?:out[- ]of[- ]scope|do not modify|do not touch|forbidden|excluded)\b/i;
7
+ const PLACEHOLDER_PATTERNS = [
8
+ /^<[^>]+>$/, // <placeholder>
9
+ /^\.{2,}$/, // ...
10
+ /^(?:in-scope|out-of-scope)\s+(?:files|surfaces)/i // bullet that is the template label, not a real path
11
+ ];
12
+ const AUTO_ALLOWED_PATHS = [
13
+ /^\.peaks\//,
14
+ /^\.peaks-artifacts\//,
15
+ /^\.git\//
16
+ ];
17
+ const AUTO_ALLOWED_TEST_FILE = /\.(?:test|spec)\.[a-z]+$/i;
18
+ const AUTO_ALLOWED_TEST_DIR = /(?:^|\/)(?:tests?|__tests__|__mocks__|test|spec)\//;
19
+ function isPlaceholder(text) {
20
+ return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(text));
21
+ }
22
+ function escapeRegex(text) {
23
+ return text.replace(/[.+^${}()|[\]\\]/g, '\\$&');
24
+ }
25
+ /**
26
+ * Convert a simple glob pattern to a regex.
27
+ * Supports `**` (any path including separators), `*` (one path segment), `?` (single char).
28
+ * Leading `/` is removed; matching is relative to project root.
29
+ */
30
+ export function globToRegex(pattern) {
31
+ const trimmed = pattern.replace(/^\.?\/+/, '').replace(/\/+$/, '');
32
+ let body = '';
33
+ let i = 0;
34
+ while (i < trimmed.length) {
35
+ const ch = trimmed[i] ?? '';
36
+ if (ch === '*') {
37
+ if (trimmed[i + 1] === '*') {
38
+ body += '.*';
39
+ i += 2;
40
+ // Skip following slash if any (since '**/' should match zero or more dirs)
41
+ if (trimmed[i] === '/')
42
+ i += 1;
43
+ }
44
+ else {
45
+ body += '[^/]*';
46
+ i += 1;
47
+ }
48
+ continue;
49
+ }
50
+ if (ch === '?') {
51
+ body += '[^/]';
52
+ i += 1;
53
+ continue;
54
+ }
55
+ body += escapeRegex(ch);
56
+ i += 1;
57
+ }
58
+ // If the pattern ends with no trailing slash and no extension wildcard, also allow it to match files under the path (treat as dir prefix)
59
+ // E.g. `src/services/login` should match `src/services/login/handler.ts`.
60
+ if (!trimmed.includes('*') && !trimmed.includes('?') && !trimmed.includes('.')) {
61
+ body = `${body}(?:/.*)?`;
62
+ }
63
+ return new RegExp(`^${body}$`);
64
+ }
65
+ function classifyPatternLine(raw) {
66
+ // Strip leading "- ", "* ", numbered list, or trailing comments.
67
+ const cleaned = raw.replace(/^\s*[-*+]\s*/, '').replace(/^\s*\d+\.\s*/, '').trim();
68
+ if (cleaned.length === 0)
69
+ return { pattern: null };
70
+ if (isPlaceholder(cleaned))
71
+ return { pattern: null };
72
+ // Take the first word/path-like token before whitespace or backticks.
73
+ // If the line wraps a path in backticks, extract it; otherwise take the whole line.
74
+ const backtickMatch = /`([^`]+)`/.exec(cleaned);
75
+ if (backtickMatch !== null && backtickMatch[1] !== undefined) {
76
+ return { pattern: backtickMatch[1].trim() };
77
+ }
78
+ // If the cleaned line is just a path-ish token, take it as-is.
79
+ if (/^[\w./*?{}[\]@-]+$/.test(cleaned)) {
80
+ return { pattern: cleaned };
81
+ }
82
+ // Otherwise the bullet is descriptive prose (e.g. "do not touch payment module"); skip.
83
+ return { pattern: null };
84
+ }
85
+ function parseRedLineScope(rdBody) {
86
+ const lines = rdBody.split(/\r?\n/);
87
+ let inSection = false;
88
+ let mode = 'unspecified';
89
+ const inScope = [];
90
+ const outOfScope = [];
91
+ for (let i = 0; i < lines.length; i += 1) {
92
+ const raw = lines[i] ?? '';
93
+ if (!inSection) {
94
+ if (RED_LINE_HEADER.test(raw)) {
95
+ inSection = true;
96
+ }
97
+ continue;
98
+ }
99
+ if (/^##\s/.test(raw))
100
+ break; // next H2
101
+ if (IN_SCOPE_SUBHEADER.test(raw.trim())) {
102
+ mode = 'in';
103
+ continue;
104
+ }
105
+ if (OUT_OF_SCOPE_SUBHEADER.test(raw.trim())) {
106
+ mode = 'out';
107
+ continue;
108
+ }
109
+ const { pattern } = classifyPatternLine(raw);
110
+ if (pattern === null)
111
+ continue;
112
+ const target = mode === 'out' || (mode === 'unspecified' && OUT_OF_SCOPE_INLINE.test(raw))
113
+ ? outOfScope
114
+ : inScope;
115
+ target.push({ raw: pattern, regex: globToRegex(pattern), line: i + 1 });
116
+ }
117
+ const declared = inScope.length > 0 || outOfScope.length > 0;
118
+ return { inScope, outOfScope, declared };
119
+ }
120
+ function tryGitChangedFiles(projectRoot, baseRef) {
121
+ try {
122
+ const trackedRaw = execFileSync('git', ['-C', projectRoot, 'diff', '--name-only', baseRef], { encoding: 'utf8' });
123
+ const tracked = trackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
124
+ const untrackedRaw = execFileSync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8' });
125
+ const untracked = untrackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
126
+ return { ok: true, files: Array.from(new Set([...tracked, ...untracked])) };
127
+ }
128
+ catch {
129
+ return { ok: false, files: [] };
130
+ }
131
+ }
132
+ function isAutoAllowed(path) {
133
+ if (AUTO_ALLOWED_PATHS.some((pattern) => pattern.test(path)))
134
+ return true;
135
+ if (AUTO_ALLOWED_TEST_FILE.test(path))
136
+ return true;
137
+ if (AUTO_ALLOWED_TEST_DIR.test(path))
138
+ return true;
139
+ return false;
140
+ }
141
+ function classifyFile(path, inScope, outOfScope) {
142
+ if (isAutoAllowed(path)) {
143
+ return { classification: 'auto-allowed', reason: 'Auto-allowed (test, mock, or Peaks artifact path)' };
144
+ }
145
+ const outMatch = outOfScope.find((pattern) => pattern.regex.test(path));
146
+ if (outMatch !== undefined) {
147
+ return { classification: 'out-of-scope-violation', matchedPattern: outMatch.raw, reason: `Matches explicit out-of-scope pattern "${outMatch.raw}"` };
148
+ }
149
+ const inMatch = inScope.find((pattern) => pattern.regex.test(path));
150
+ if (inMatch !== undefined) {
151
+ return { classification: 'in-scope', matchedPattern: inMatch.raw, reason: `Matches in-scope pattern "${inMatch.raw}"` };
152
+ }
153
+ return { classification: 'unclassified', reason: 'Does not match any declared scope pattern' };
154
+ }
155
+ export async function getDiffVsScope(options) {
156
+ const baseRef = options.baseRef ?? 'HEAD';
157
+ const showOptions = {
158
+ projectRoot: options.projectRoot,
159
+ role: 'rd',
160
+ requestId: options.requestId
161
+ };
162
+ if (options.sessionId !== undefined) {
163
+ showOptions.sessionId = options.sessionId;
164
+ }
165
+ const rdArtifact = await showRequestArtifact(showOptions);
166
+ if (rdArtifact === null) {
167
+ return { kind: 'rd-not-found' };
168
+ }
169
+ const { inScope, outOfScope, declared } = parseRedLineScope(rdArtifact.content);
170
+ const { ok: gitAvailable, files } = tryGitChangedFiles(options.projectRoot, baseRef);
171
+ const changedFiles = files.map((path) => {
172
+ const { classification, matchedPattern, reason } = classifyFile(path, inScope, outOfScope);
173
+ const entry = { path, classification, reason };
174
+ if (matchedPattern !== undefined) {
175
+ entry.matchedPattern = matchedPattern;
176
+ }
177
+ return entry;
178
+ });
179
+ const violations = changedFiles.filter((file) => file.classification === 'out-of-scope-violation');
180
+ const unclassified = changedFiles.filter((file) => file.classification === 'unclassified');
181
+ // ok if patterns are declared AND no violations AND no unclassified non-trivial files.
182
+ // If patterns were NOT declared, treat as a warning (ok=false but with a clear "patterns missing" reason).
183
+ const ok = gitAvailable && declared && violations.length === 0 && unclassified.length === 0;
184
+ return {
185
+ ok,
186
+ rdArtifactPath: rdArtifact.path,
187
+ inScopePatterns: inScope,
188
+ outOfScopePatterns: outOfScope,
189
+ changedFiles,
190
+ violations,
191
+ unclassified,
192
+ gitAvailable,
193
+ patternsDeclared: declared
194
+ };
195
+ }
196
+ export function isDiffScopeError(value) {
197
+ return value.kind !== undefined;
198
+ }
@@ -0,0 +1,7 @@
1
+ import type { ExistingSystemReport } from './scan-types.js';
2
+ export type ExistingSystemScanOptions = {
3
+ projectRoot: string;
4
+ maxTokens?: number;
5
+ maxSamplesPerKind?: number;
6
+ };
7
+ export declare function scanExistingSystem(options: ExistingSystemScanOptions): Promise<ExistingSystemReport>;