Skip to content

Commit a97d7b5

Browse files
committed
feat: add path length validation for Windows MAX_PATH and capability portability
Dual check: warn when full path exceeds 260 chars (Windows MAX_PATH) or when capability path exceeds 160 chars (portability risk).
1 parent 94e6e36 commit a97d7b5

2 files changed

Lines changed: 95 additions & 0 deletions

File tree

src/utils/spec-discovery.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,27 @@ export function validateSpecStructure(
333333
}
334334
}
335335

336+
// Check path length (Windows MAX_PATH compatibility)
337+
if (validatePaths) {
338+
const WINDOWS_MAX_PATH = 260;
339+
const MAX_CAPABILITY_LENGTH = 160; // Leaves ~100 chars for project root + openspec/specs/ + /spec.md
340+
for (const spec of specs) {
341+
if (spec.path.length > WINDOWS_MAX_PATH) {
342+
issues.push({
343+
level: 'WARNING',
344+
message: `Spec "${spec.capability}" has a full path of ${spec.path.length} characters, exceeding Windows MAX_PATH (${WINDOWS_MAX_PATH}). This will cause issues on Windows.`,
345+
capability: spec.capability,
346+
});
347+
} else if (spec.capability.length > MAX_CAPABILITY_LENGTH) {
348+
issues.push({
349+
level: 'WARNING',
350+
message: `Spec "${spec.capability}" has a capability path of ${spec.capability.length} characters (max recommended: ${MAX_CAPABILITY_LENGTH}). Long paths may cause issues on Windows depending on project location.`,
351+
capability: spec.capability,
352+
});
353+
}
354+
}
355+
}
356+
336357
// Check naming conventions (if enabled)
337358
if (validatePaths) {
338359
const VALID_NAME_PATTERN = /^[a-z0-9-_]+$/;

test/utils/spec-discovery.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,80 @@ describe('spec-discovery', () => {
630630
});
631631
});
632632

633+
describe('validateSpecStructure() - path length validation', () => {
634+
it('should warn when full path exceeds Windows MAX_PATH (260)', () => {
635+
// Build a path that exceeds 260 chars total
636+
const prefix = '/a-long-project-root-directory/with/many/levels/openspec/specs/';
637+
const longSegments = Array.from({ length: 8 }, (_, i) => `segment-${String(i).padStart(2, '0')}-with-extra-padding`);
638+
const capability = longSegments.join(path.sep);
639+
const fullPath = prefix + longSegments.join('/') + '/spec.md';
640+
641+
expect(fullPath.length).toBeGreaterThan(260);
642+
643+
const specs: DiscoveredSpec[] = [
644+
{ capability, path: fullPath, depth: 8 },
645+
];
646+
647+
const issues = validateSpecStructure(specs, { validatePaths: true, maxDepth: 10 });
648+
const lengthIssues = issues.filter(i => i.message.includes('characters'));
649+
650+
expect(lengthIssues).toHaveLength(1);
651+
expect(lengthIssues[0].level).toBe('WARNING');
652+
expect(lengthIssues[0].message).toContain('Windows MAX_PATH');
653+
});
654+
655+
it('should not warn for paths under 260 characters', () => {
656+
const specs: DiscoveredSpec[] = [
657+
{ capability: path.join('platform', 'services', 'api'), path: '/specs/platform/services/api/spec.md', depth: 3 },
658+
];
659+
660+
const issues = validateSpecStructure(specs, { validatePaths: true, maxDepth: 4 });
661+
const lengthIssues = issues.filter(i => i.message.includes('characters'));
662+
663+
expect(lengthIssues).toHaveLength(0);
664+
});
665+
666+
it('should warn when capability path exceeds 160 characters (even if full path under 260)', () => {
667+
// Build a capability that exceeds 160 chars but keep full path under 260
668+
const longSegments = Array.from({ length: 6 }, (_, i) => `long-segment-name-${String(i).padStart(2, '0')}-padding`);
669+
const capability = longSegments.join(path.sep);
670+
671+
expect(capability.length).toBeGreaterThan(160);
672+
673+
// Short prefix keeps full path under 260
674+
const fullPath = '/specs/' + longSegments.join('/') + '/spec.md';
675+
expect(fullPath.length).toBeLessThan(260);
676+
677+
const specs: DiscoveredSpec[] = [
678+
{ capability, path: fullPath, depth: 6 },
679+
];
680+
681+
const issues = validateSpecStructure(specs, { validatePaths: true, maxDepth: 10 });
682+
const lengthIssues = issues.filter(i => i.message.includes('characters'));
683+
684+
expect(lengthIssues).toHaveLength(1);
685+
expect(lengthIssues[0].level).toBe('WARNING');
686+
expect(lengthIssues[0].message).toContain('capability path');
687+
expect(lengthIssues[0].message).toContain('160');
688+
});
689+
690+
it('should skip path length check when validatePaths is false', () => {
691+
const prefix = '/a-long-project-root-directory/with/many/levels/openspec/specs/';
692+
const longSegments = Array.from({ length: 8 }, (_, i) => `segment-${String(i).padStart(2, '0')}-with-extra-padding`);
693+
const capability = longSegments.join(path.sep);
694+
const fullPath = prefix + longSegments.join('/') + '/spec.md';
695+
696+
const specs: DiscoveredSpec[] = [
697+
{ capability, path: fullPath, depth: 8 },
698+
];
699+
700+
const issues = validateSpecStructure(specs, { validatePaths: false, maxDepth: 10 });
701+
const lengthIssues = issues.filter(i => i.message.includes('characters'));
702+
703+
expect(lengthIssues).toHaveLength(0);
704+
});
705+
});
706+
633707
describe('validateSpecStructure() - reserved names validation', () => {
634708
it('should reject reserved directory names', () => {
635709
const reservedNames = [

0 commit comments

Comments
 (0)