cap-pro 1.0.0
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/.claude-plugin/README.md +26 -0
- package/.claude-plugin/marketplace.json +24 -0
- package/.claude-plugin/plugin.json +24 -0
- package/LICENSE +21 -0
- package/README.ja-JP.md +834 -0
- package/README.ko-KR.md +823 -0
- package/README.md +806 -0
- package/README.pt-BR.md +452 -0
- package/README.zh-CN.md +800 -0
- package/agents/cap-architect.md +269 -0
- package/agents/cap-brainstormer.md +207 -0
- package/agents/cap-curator.md +276 -0
- package/agents/cap-debugger.md +365 -0
- package/agents/cap-designer.md +246 -0
- package/agents/cap-historian.md +464 -0
- package/agents/cap-migrator.md +291 -0
- package/agents/cap-prototyper.md +197 -0
- package/agents/cap-validator.md +308 -0
- package/bin/install.js +5433 -0
- package/cap/bin/cap-tools.cjs +853 -0
- package/cap/bin/lib/arc-scanner.cjs +344 -0
- package/cap/bin/lib/cap-affinity-engine.cjs +862 -0
- package/cap/bin/lib/cap-anchor.cjs +228 -0
- package/cap/bin/lib/cap-annotation-writer.cjs +340 -0
- package/cap/bin/lib/cap-checkpoint.cjs +434 -0
- package/cap/bin/lib/cap-cluster-detect.cjs +945 -0
- package/cap/bin/lib/cap-cluster-display.cjs +52 -0
- package/cap/bin/lib/cap-cluster-format.cjs +245 -0
- package/cap/bin/lib/cap-cluster-helpers.cjs +295 -0
- package/cap/bin/lib/cap-cluster-io.cjs +212 -0
- package/cap/bin/lib/cap-completeness.cjs +540 -0
- package/cap/bin/lib/cap-deps.cjs +583 -0
- package/cap/bin/lib/cap-design-families.cjs +332 -0
- package/cap/bin/lib/cap-design.cjs +966 -0
- package/cap/bin/lib/cap-divergence-detector.cjs +400 -0
- package/cap/bin/lib/cap-doctor.cjs +752 -0
- package/cap/bin/lib/cap-feature-map-internals.cjs +19 -0
- package/cap/bin/lib/cap-feature-map-migrate.cjs +335 -0
- package/cap/bin/lib/cap-feature-map-monorepo.cjs +885 -0
- package/cap/bin/lib/cap-feature-map-shard.cjs +315 -0
- package/cap/bin/lib/cap-feature-map.cjs +1943 -0
- package/cap/bin/lib/cap-fitness-score.cjs +1075 -0
- package/cap/bin/lib/cap-impact-analysis.cjs +652 -0
- package/cap/bin/lib/cap-learn-review.cjs +1072 -0
- package/cap/bin/lib/cap-learning-signals.cjs +627 -0
- package/cap/bin/lib/cap-loader.cjs +227 -0
- package/cap/bin/lib/cap-logger.cjs +57 -0
- package/cap/bin/lib/cap-memory-bridge.cjs +764 -0
- package/cap/bin/lib/cap-memory-confidence.cjs +452 -0
- package/cap/bin/lib/cap-memory-dir.cjs +987 -0
- package/cap/bin/lib/cap-memory-engine.cjs +698 -0
- package/cap/bin/lib/cap-memory-extends.cjs +398 -0
- package/cap/bin/lib/cap-memory-graph.cjs +790 -0
- package/cap/bin/lib/cap-memory-migrate.cjs +2015 -0
- package/cap/bin/lib/cap-memory-pin.cjs +183 -0
- package/cap/bin/lib/cap-memory-platform.cjs +490 -0
- package/cap/bin/lib/cap-memory-prune.cjs +707 -0
- package/cap/bin/lib/cap-memory-schema.cjs +812 -0
- package/cap/bin/lib/cap-migrate-tags.cjs +309 -0
- package/cap/bin/lib/cap-migrate.cjs +540 -0
- package/cap/bin/lib/cap-pattern-apply.cjs +1203 -0
- package/cap/bin/lib/cap-pattern-pipeline.cjs +1034 -0
- package/cap/bin/lib/cap-plugin-manifest.cjs +80 -0
- package/cap/bin/lib/cap-realtime-affinity.cjs +399 -0
- package/cap/bin/lib/cap-reconcile.cjs +570 -0
- package/cap/bin/lib/cap-research-gate.cjs +218 -0
- package/cap/bin/lib/cap-scope-filter.cjs +402 -0
- package/cap/bin/lib/cap-semantic-pipeline.cjs +1038 -0
- package/cap/bin/lib/cap-session-extract.cjs +987 -0
- package/cap/bin/lib/cap-session.cjs +445 -0
- package/cap/bin/lib/cap-snapshot-linkage.cjs +963 -0
- package/cap/bin/lib/cap-stack-docs.cjs +646 -0
- package/cap/bin/lib/cap-tag-observer.cjs +371 -0
- package/cap/bin/lib/cap-tag-scanner.cjs +1766 -0
- package/cap/bin/lib/cap-telemetry.cjs +466 -0
- package/cap/bin/lib/cap-test-audit.cjs +1438 -0
- package/cap/bin/lib/cap-thread-migrator.cjs +307 -0
- package/cap/bin/lib/cap-thread-synthesis.cjs +545 -0
- package/cap/bin/lib/cap-thread-tracker.cjs +519 -0
- package/cap/bin/lib/cap-trace.cjs +399 -0
- package/cap/bin/lib/cap-trust-mode.cjs +336 -0
- package/cap/bin/lib/cap-ui-design-editor.cjs +642 -0
- package/cap/bin/lib/cap-ui-mind-map.cjs +712 -0
- package/cap/bin/lib/cap-ui-thread-nav.cjs +693 -0
- package/cap/bin/lib/cap-ui.cjs +1245 -0
- package/cap/bin/lib/cap-upgrade.cjs +1028 -0
- package/cap/bin/lib/cli/arg-helpers.cjs +49 -0
- package/cap/bin/lib/cli/frontmatter-router.cjs +31 -0
- package/cap/bin/lib/cli/init-router.cjs +68 -0
- package/cap/bin/lib/cli/phase-router.cjs +102 -0
- package/cap/bin/lib/cli/state-router.cjs +61 -0
- package/cap/bin/lib/cli/template-router.cjs +37 -0
- package/cap/bin/lib/cli/uat-router.cjs +29 -0
- package/cap/bin/lib/cli/validation-router.cjs +26 -0
- package/cap/bin/lib/cli/verification-router.cjs +31 -0
- package/cap/bin/lib/cli/workstream-router.cjs +39 -0
- package/cap/bin/lib/commands.cjs +961 -0
- package/cap/bin/lib/config.cjs +467 -0
- package/cap/bin/lib/convention-reader.cjs +258 -0
- package/cap/bin/lib/core.cjs +1241 -0
- package/cap/bin/lib/feature-aggregator.cjs +423 -0
- package/cap/bin/lib/frontmatter.cjs +337 -0
- package/cap/bin/lib/init.cjs +1443 -0
- package/cap/bin/lib/manifest-generator.cjs +383 -0
- package/cap/bin/lib/milestone.cjs +253 -0
- package/cap/bin/lib/model-profiles.cjs +69 -0
- package/cap/bin/lib/monorepo-context.cjs +226 -0
- package/cap/bin/lib/monorepo-migrator.cjs +509 -0
- package/cap/bin/lib/phase.cjs +889 -0
- package/cap/bin/lib/profile-output.cjs +989 -0
- package/cap/bin/lib/profile-pipeline.cjs +540 -0
- package/cap/bin/lib/roadmap.cjs +330 -0
- package/cap/bin/lib/security.cjs +394 -0
- package/cap/bin/lib/session-manager.cjs +292 -0
- package/cap/bin/lib/skeleton-generator.cjs +179 -0
- package/cap/bin/lib/state.cjs +1032 -0
- package/cap/bin/lib/template.cjs +231 -0
- package/cap/bin/lib/test-detector.cjs +62 -0
- package/cap/bin/lib/uat.cjs +283 -0
- package/cap/bin/lib/verify.cjs +889 -0
- package/cap/bin/lib/workspace-detector.cjs +371 -0
- package/cap/bin/lib/workstream.cjs +492 -0
- package/cap/commands/gsd/workstreams.md +63 -0
- package/cap/references/arc-standard.md +315 -0
- package/cap/references/cap-agent-architecture.md +101 -0
- package/cap/references/cap-gitignore-template +9 -0
- package/cap/references/cap-zero-deps.md +158 -0
- package/cap/references/checkpoints.md +778 -0
- package/cap/references/continuation-format.md +249 -0
- package/cap/references/contract-test-templates.md +312 -0
- package/cap/references/feature-map-template.md +25 -0
- package/cap/references/git-integration.md +295 -0
- package/cap/references/git-planning-commit.md +38 -0
- package/cap/references/model-profiles.md +174 -0
- package/cap/references/phase-numbering.md +126 -0
- package/cap/references/planning-config.md +202 -0
- package/cap/references/property-test-templates.md +316 -0
- package/cap/references/security-test-templates.md +347 -0
- package/cap/references/session-template.json +8 -0
- package/cap/references/tdd.md +263 -0
- package/cap/references/user-profiling.md +681 -0
- package/cap/references/verification-patterns.md +612 -0
- package/cap/templates/UAT.md +265 -0
- package/cap/templates/claude-md.md +175 -0
- package/cap/templates/codebase/architecture.md +255 -0
- package/cap/templates/codebase/concerns.md +310 -0
- package/cap/templates/codebase/conventions.md +307 -0
- package/cap/templates/codebase/integrations.md +280 -0
- package/cap/templates/codebase/stack.md +186 -0
- package/cap/templates/codebase/structure.md +285 -0
- package/cap/templates/codebase/testing.md +480 -0
- package/cap/templates/config.json +44 -0
- package/cap/templates/context.md +352 -0
- package/cap/templates/continue-here.md +78 -0
- package/cap/templates/copilot-instructions.md +7 -0
- package/cap/templates/debug-subagent-prompt.md +91 -0
- package/cap/templates/discussion-log.md +63 -0
- package/cap/templates/milestone-archive.md +123 -0
- package/cap/templates/milestone.md +115 -0
- package/cap/templates/phase-prompt.md +610 -0
- package/cap/templates/planner-subagent-prompt.md +117 -0
- package/cap/templates/project.md +186 -0
- package/cap/templates/requirements.md +231 -0
- package/cap/templates/research-project/ARCHITECTURE.md +204 -0
- package/cap/templates/research-project/FEATURES.md +147 -0
- package/cap/templates/research-project/PITFALLS.md +200 -0
- package/cap/templates/research-project/STACK.md +120 -0
- package/cap/templates/research-project/SUMMARY.md +170 -0
- package/cap/templates/research.md +552 -0
- package/cap/templates/roadmap.md +202 -0
- package/cap/templates/state.md +176 -0
- package/cap/templates/summary.md +364 -0
- package/cap/templates/user-preferences.md +498 -0
- package/cap/templates/verification-report.md +322 -0
- package/cap/workflows/add-phase.md +112 -0
- package/cap/workflows/add-tests.md +351 -0
- package/cap/workflows/add-todo.md +158 -0
- package/cap/workflows/audit-milestone.md +340 -0
- package/cap/workflows/audit-uat.md +109 -0
- package/cap/workflows/autonomous.md +891 -0
- package/cap/workflows/check-todos.md +177 -0
- package/cap/workflows/cleanup.md +152 -0
- package/cap/workflows/complete-milestone.md +767 -0
- package/cap/workflows/diagnose-issues.md +231 -0
- package/cap/workflows/discovery-phase.md +289 -0
- package/cap/workflows/discuss-phase-assumptions.md +653 -0
- package/cap/workflows/discuss-phase.md +1049 -0
- package/cap/workflows/do.md +104 -0
- package/cap/workflows/execute-phase.md +846 -0
- package/cap/workflows/execute-plan.md +514 -0
- package/cap/workflows/fast.md +105 -0
- package/cap/workflows/forensics.md +265 -0
- package/cap/workflows/health.md +181 -0
- package/cap/workflows/help.md +660 -0
- package/cap/workflows/insert-phase.md +130 -0
- package/cap/workflows/list-phase-assumptions.md +178 -0
- package/cap/workflows/list-workspaces.md +56 -0
- package/cap/workflows/manager.md +362 -0
- package/cap/workflows/map-codebase.md +377 -0
- package/cap/workflows/milestone-summary.md +223 -0
- package/cap/workflows/new-milestone.md +486 -0
- package/cap/workflows/new-project.md +1250 -0
- package/cap/workflows/new-workspace.md +237 -0
- package/cap/workflows/next.md +97 -0
- package/cap/workflows/node-repair.md +92 -0
- package/cap/workflows/note.md +156 -0
- package/cap/workflows/pause-work.md +176 -0
- package/cap/workflows/plan-milestone-gaps.md +273 -0
- package/cap/workflows/plan-phase.md +857 -0
- package/cap/workflows/plant-seed.md +169 -0
- package/cap/workflows/pr-branch.md +129 -0
- package/cap/workflows/profile-user.md +449 -0
- package/cap/workflows/progress.md +507 -0
- package/cap/workflows/quick.md +757 -0
- package/cap/workflows/remove-phase.md +155 -0
- package/cap/workflows/remove-workspace.md +90 -0
- package/cap/workflows/research-phase.md +82 -0
- package/cap/workflows/resume-project.md +326 -0
- package/cap/workflows/review.md +228 -0
- package/cap/workflows/session-report.md +146 -0
- package/cap/workflows/settings.md +283 -0
- package/cap/workflows/ship.md +228 -0
- package/cap/workflows/stats.md +60 -0
- package/cap/workflows/transition.md +671 -0
- package/cap/workflows/ui-phase.md +298 -0
- package/cap/workflows/ui-review.md +161 -0
- package/cap/workflows/update.md +323 -0
- package/cap/workflows/validate-phase.md +170 -0
- package/cap/workflows/verify-phase.md +254 -0
- package/cap/workflows/verify-work.md +637 -0
- package/commands/cap/annotate.md +165 -0
- package/commands/cap/brainstorm.md +393 -0
- package/commands/cap/checkpoint.md +106 -0
- package/commands/cap/completeness.md +94 -0
- package/commands/cap/continue.md +72 -0
- package/commands/cap/debug.md +588 -0
- package/commands/cap/deps.md +169 -0
- package/commands/cap/design.md +479 -0
- package/commands/cap/init.md +354 -0
- package/commands/cap/iterate.md +249 -0
- package/commands/cap/learn.md +459 -0
- package/commands/cap/memory.md +275 -0
- package/commands/cap/migrate-feature-map.md +91 -0
- package/commands/cap/migrate-memory.md +108 -0
- package/commands/cap/migrate-tags.md +91 -0
- package/commands/cap/migrate.md +131 -0
- package/commands/cap/prototype.md +510 -0
- package/commands/cap/reconcile.md +121 -0
- package/commands/cap/review.md +360 -0
- package/commands/cap/save.md +72 -0
- package/commands/cap/scan.md +404 -0
- package/commands/cap/start.md +356 -0
- package/commands/cap/status.md +118 -0
- package/commands/cap/test-audit.md +262 -0
- package/commands/cap/test.md +394 -0
- package/commands/cap/trace.md +133 -0
- package/commands/cap/ui.md +167 -0
- package/hooks/dist/cap-check-update.js +115 -0
- package/hooks/dist/cap-context-monitor.js +185 -0
- package/hooks/dist/cap-learn-review-hook.js +114 -0
- package/hooks/dist/cap-learning-hook.js +192 -0
- package/hooks/dist/cap-memory.js +299 -0
- package/hooks/dist/cap-prompt-guard.js +97 -0
- package/hooks/dist/cap-statusline.js +157 -0
- package/hooks/dist/cap-tag-observer.js +115 -0
- package/hooks/dist/cap-version-check.js +112 -0
- package/hooks/dist/cap-workflow-guard.js +175 -0
- package/hooks/hooks.json +55 -0
- package/package.json +58 -0
- package/scripts/base64-scan.sh +262 -0
- package/scripts/build-hooks.js +93 -0
- package/scripts/cap-removal-checklist.md +202 -0
- package/scripts/prompt-injection-scan.sh +199 -0
- package/scripts/run-tests.cjs +181 -0
- package/scripts/secret-scan.sh +227 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// @cap-feature(feature:F-059) Research-First Gate Before Prototype — pure-logic module.
|
|
2
|
+
// @cap-decision Pure logic + reporting only. The prototype command is responsible for prompting the user and invoking refresh-docs; this module never blocks and never reads stdin.
|
|
3
|
+
// @cap-decision Library extraction is a two-pass match: (1) exact token match against package.json dependency names, (2) substring match inside AC descriptions. A library must appear in package.json to be considered (zero false-positives from prose mentions like "proven pattern").
|
|
4
|
+
// @cap-constraint Zero external dependencies — node: built-ins only.
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
|
|
11
|
+
const stackDocs = require('./cap-stack-docs.cjs');
|
|
12
|
+
|
|
13
|
+
/** Default staleness threshold (days). ACs spec 30 days. */
|
|
14
|
+
const DEFAULT_MAX_AGE_DAYS = 30;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} GateResult
|
|
18
|
+
* @property {string[]} libraries - Libraries referenced by the scoped ACs + present in package.json
|
|
19
|
+
* @property {string[]} missing - Libraries with no cached docs at all
|
|
20
|
+
* @property {string[]} stale - Libraries with docs older than maxAgeDays
|
|
21
|
+
* @property {string[]} fresh - Libraries with docs within the freshness window
|
|
22
|
+
* @property {number} maxAgeDays - Staleness threshold applied
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load the direct (dependencies + devDependencies) name list from a package.json path.
|
|
27
|
+
* Tolerant of missing / malformed input — returns [] on any failure.
|
|
28
|
+
* @param {string} projectRoot
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
function readPackageDependencies(projectRoot) {
|
|
32
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
33
|
+
if (!fs.existsSync(pkgPath)) return [];
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
36
|
+
const deps = pkg.dependencies && typeof pkg.dependencies === 'object' ? Object.keys(pkg.dependencies) : [];
|
|
37
|
+
const devDeps = pkg.devDependencies && typeof pkg.devDependencies === 'object' ? Object.keys(pkg.devDependencies) : [];
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const n of [...deps, ...devDeps]) {
|
|
41
|
+
if (typeof n === 'string' && n.length > 0 && !seen.has(n)) {
|
|
42
|
+
seen.add(n);
|
|
43
|
+
out.push(n);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out.sort();
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Escape a dependency name for safe embedding in a RegExp. npm scoped names
|
|
54
|
+
* contain '/', '@', '.', '-' which are regex metacharacters in some positions.
|
|
55
|
+
* @param {string} name
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
function escapeRegex(name) {
|
|
59
|
+
return name.replace(/[\\^$.*+?()[\]{}|/]/g, '\\$&');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// @cap-todo(ac:F-059/AC-1) parseLibraryMentions scans AC descriptions for any dependency listed in package.json. We deliberately use package.json as the whitelist — a mention like "proven pattern" or "stripe webhook" in prose will not count unless the dep is installed. This keeps the gate specific and actionable.
|
|
63
|
+
/**
|
|
64
|
+
* Scan AC description strings for references to any library listed in `dependencies`.
|
|
65
|
+
* Uses whole-token boundaries so `react` doesn't match `overreacted` or `reactivity`.
|
|
66
|
+
*
|
|
67
|
+
* @param {string[]} acDescriptions - One description per AC
|
|
68
|
+
* @param {string[]} dependencies - Library names from package.json
|
|
69
|
+
* @returns {string[]} Sorted unique dependency names that appeared in any description
|
|
70
|
+
*/
|
|
71
|
+
function parseLibraryMentions(acDescriptions, dependencies) {
|
|
72
|
+
if (!Array.isArray(acDescriptions) || acDescriptions.length === 0) return [];
|
|
73
|
+
if (!Array.isArray(dependencies) || dependencies.length === 0) return [];
|
|
74
|
+
const haystack = acDescriptions.filter((s) => typeof s === 'string').join('\n').toLowerCase();
|
|
75
|
+
if (haystack.length === 0) return [];
|
|
76
|
+
|
|
77
|
+
const hits = new Set();
|
|
78
|
+
for (const dep of dependencies) {
|
|
79
|
+
if (typeof dep !== 'string' || dep.length === 0) continue;
|
|
80
|
+
const lower = dep.toLowerCase();
|
|
81
|
+
// Word-boundary matching: the name must not be embedded inside a longer identifier.
|
|
82
|
+
// Scoped packages ("@org/pkg") already contain non-word characters so plain \b works.
|
|
83
|
+
const re = new RegExp(`(?:^|[^a-z0-9@/_-])${escapeRegex(lower)}(?:$|[^a-z0-9@/_-])`, 'i');
|
|
84
|
+
if (re.test(haystack)) hits.add(dep);
|
|
85
|
+
}
|
|
86
|
+
return Array.from(hits).sort();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// @cap-todo(ac:F-059/AC-2) checkStackDocs buckets each library into missing / stale / fresh using cap-stack-docs.checkFreshness. The spec named `.cap/stack-docs/{library}/` as a directory; F-004 stores docs as `{library}.md` files — we honour the existing on-disk convention and treat the AC wording as the *intent* (a per-library artefact, not its exact layout).
|
|
90
|
+
/**
|
|
91
|
+
* For each library, check whether its stack doc is missing, stale, or fresh.
|
|
92
|
+
* @param {string} projectRoot
|
|
93
|
+
* @param {string[]} libraries
|
|
94
|
+
* @param {number} [maxAgeDays=DEFAULT_MAX_AGE_DAYS]
|
|
95
|
+
* @returns {{missing:string[], stale:string[], fresh:string[]}}
|
|
96
|
+
*/
|
|
97
|
+
function checkStackDocs(projectRoot, libraries, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
|
|
98
|
+
const missing = [];
|
|
99
|
+
const stale = [];
|
|
100
|
+
const fresh = [];
|
|
101
|
+
const maxAgeHours = maxAgeDays * 24;
|
|
102
|
+
|
|
103
|
+
for (const lib of libraries || []) {
|
|
104
|
+
if (typeof lib !== 'string' || lib.length === 0) continue;
|
|
105
|
+
const freshness = stackDocs.checkFreshness(projectRoot, lib, maxAgeHours);
|
|
106
|
+
if (!freshness.filePath) {
|
|
107
|
+
missing.push(lib);
|
|
108
|
+
} else if (!freshness.fresh) {
|
|
109
|
+
stale.push(lib);
|
|
110
|
+
} else {
|
|
111
|
+
fresh.push(lib);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
missing: missing.sort(),
|
|
117
|
+
stale: stale.sort(),
|
|
118
|
+
fresh: fresh.sort(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run the full research-first gate against a scoped set of AC descriptions.
|
|
124
|
+
* Pure function — reads filesystem (package.json, stack-docs mtimes) but never prompts or logs.
|
|
125
|
+
*
|
|
126
|
+
* @param {Object} opts
|
|
127
|
+
* @param {string} opts.projectRoot - Absolute project root
|
|
128
|
+
* @param {string[]} opts.acDescriptions - AC description strings (already scoped to the features being prototyped)
|
|
129
|
+
* @param {number} [opts.maxAgeDays=DEFAULT_MAX_AGE_DAYS]
|
|
130
|
+
* @param {string[]} [opts.dependencies] - Override package-json detection (for testing)
|
|
131
|
+
* @returns {GateResult}
|
|
132
|
+
*/
|
|
133
|
+
function runGate(opts) {
|
|
134
|
+
const projectRoot = opts && opts.projectRoot;
|
|
135
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
136
|
+
throw new TypeError('runGate: projectRoot must be a non-empty string');
|
|
137
|
+
}
|
|
138
|
+
const acDescriptions = Array.isArray(opts.acDescriptions) ? opts.acDescriptions : [];
|
|
139
|
+
const maxAgeDays = typeof opts.maxAgeDays === 'number' && opts.maxAgeDays > 0
|
|
140
|
+
? opts.maxAgeDays
|
|
141
|
+
: DEFAULT_MAX_AGE_DAYS;
|
|
142
|
+
const dependencies = Array.isArray(opts.dependencies) ? opts.dependencies : readPackageDependencies(projectRoot);
|
|
143
|
+
|
|
144
|
+
const libraries = parseLibraryMentions(acDescriptions, dependencies);
|
|
145
|
+
const buckets = checkStackDocs(projectRoot, libraries, maxAgeDays);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
libraries,
|
|
149
|
+
missing: buckets.missing,
|
|
150
|
+
stale: buckets.stale,
|
|
151
|
+
fresh: buckets.fresh,
|
|
152
|
+
maxAgeDays,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// @cap-todo(ac:F-059/AC-3) formatWarning renders the user-facing block including the /cap:refresh-docs hint. Empty when nothing is missing/stale (caller can skip printing).
|
|
157
|
+
/**
|
|
158
|
+
* Render a human-readable warning block for the prototype orchestrator to print.
|
|
159
|
+
* Returns an empty string when there is nothing to warn about.
|
|
160
|
+
* @param {GateResult} result
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
function formatWarning(result) {
|
|
164
|
+
const missing = result && Array.isArray(result.missing) ? result.missing : [];
|
|
165
|
+
const stale = result && Array.isArray(result.stale) ? result.stale : [];
|
|
166
|
+
if (missing.length === 0 && stale.length === 0) return '';
|
|
167
|
+
|
|
168
|
+
const lines = ['Research-First Gate — missing or stale stack docs detected:'];
|
|
169
|
+
if (missing.length > 0) {
|
|
170
|
+
lines.push(` Missing: ${missing.join(', ')}`);
|
|
171
|
+
}
|
|
172
|
+
if (stale.length > 0) {
|
|
173
|
+
lines.push(` Stale (> ${result.maxAgeDays} days): ${stale.join(', ')}`);
|
|
174
|
+
}
|
|
175
|
+
const refreshTargets = [...missing, ...stale];
|
|
176
|
+
lines.push('');
|
|
177
|
+
lines.push(` Recommendation: /cap:refresh-docs ${refreshTargets.join(' ')}`);
|
|
178
|
+
lines.push(' Proceed anyway? [y/N]');
|
|
179
|
+
return lines.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// @cap-todo(ac:F-059/AC-6) logGateCheck appends a compact session-log record so post-run diagnostics can correlate low-quality prototypes with skipped research.
|
|
183
|
+
/**
|
|
184
|
+
* Append a JSONL record describing the gate outcome to the session log.
|
|
185
|
+
* Best-effort — I/O failures are swallowed so the gate never blocks the prototype flow.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} projectRoot
|
|
188
|
+
* @param {{skipped?:boolean, libsChecked:number, missing:number, stale:number}} record
|
|
189
|
+
* @param {Date} [now]
|
|
190
|
+
*/
|
|
191
|
+
function logGateCheck(projectRoot, record, now) {
|
|
192
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return;
|
|
193
|
+
const logPath = path.join(projectRoot, '.cap', 'session-log.jsonl');
|
|
194
|
+
const entry = {
|
|
195
|
+
timestamp: (now instanceof Date ? now : new Date()).toISOString(),
|
|
196
|
+
event: 'research-gate',
|
|
197
|
+
skipped: !!(record && record.skipped),
|
|
198
|
+
libsChecked: record && Number.isFinite(record.libsChecked) ? record.libsChecked : 0,
|
|
199
|
+
missing: record && Number.isFinite(record.missing) ? record.missing : 0,
|
|
200
|
+
stale: record && Number.isFinite(record.stale) ? record.stale : 0,
|
|
201
|
+
};
|
|
202
|
+
try {
|
|
203
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
204
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf8');
|
|
205
|
+
} catch {
|
|
206
|
+
// Best-effort — never propagate logging failure to the caller.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
DEFAULT_MAX_AGE_DAYS,
|
|
212
|
+
readPackageDependencies,
|
|
213
|
+
parseLibraryMentions,
|
|
214
|
+
checkStackDocs,
|
|
215
|
+
runGate,
|
|
216
|
+
formatWarning,
|
|
217
|
+
logGateCheck,
|
|
218
|
+
};
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// @cap-feature(feature:F-085, primary:true) Scope filter shared by cap-tag-scanner and cap-migrate-tags.
|
|
4
|
+
//
|
|
5
|
+
// @cap-decision(F-085/AC-1) One module, two consumers. Scanner and migrator both walk the same
|
|
6
|
+
// tree with the same exclusion semantics. Duplicating the rules in both modules drifts —
|
|
7
|
+
// centralising them here keeps DEFAULT_DIR_EXCLUDES, DEFAULT_PATH_EXCLUDES and gitignore
|
|
8
|
+
// handling consistent.
|
|
9
|
+
//
|
|
10
|
+
// @cap-decision(F-085/AC-2) Gitignore is honoured at scan-projectRoot only (not nested .gitignore
|
|
11
|
+
// files). 99% of the noise on real repos is in top-level ignored dirs (.claude, node_modules,
|
|
12
|
+
// coverage). Recursive .gitignore parsing would multiply complexity for marginal coverage.
|
|
13
|
+
//
|
|
14
|
+
// @cap-decision(F-085/AC-3) Path-pattern excludes are PREFIX-matched on relative paths (not full
|
|
15
|
+
// glob). Patterns starting with `**/` are treated as suffix-anywhere. Real-world need is
|
|
16
|
+
// covered by these two shapes; full glob would require a glob compiler we don't have.
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
// @cap-decision(F-085/AC-3) DEFAULT_DIR_EXCLUDES preserves the legacy basename-matched list from
|
|
22
|
+
// cap-tag-scanner.cjs so the scanner's behaviour is byte-identical when no extra config is set.
|
|
23
|
+
const DEFAULT_DIR_EXCLUDES = Object.freeze([
|
|
24
|
+
'.git', '.cap', '.planning',
|
|
25
|
+
'node_modules', 'dist', 'build', 'coverage', 'out',
|
|
26
|
+
'.next', '.turbo', '.nx', '.cache', '.parcel-cache', '.vercel', '.svelte-kit',
|
|
27
|
+
'__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox', 'venv', '.venv',
|
|
28
|
+
'target', '.gradle', 'Pods', '.expo',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// @cap-decision(F-085/AC-3, F-085/AC-4) DEFAULT_PATH_EXCLUDES catches three classes that
|
|
32
|
+
// basename-matching alone misses:
|
|
33
|
+
// - .claude/worktrees: agent worktrees, gitignored on most projects but defensive here too
|
|
34
|
+
// - .claude/cap: plugin-self-mirror, would let migrate-tags rewrite the user-global install
|
|
35
|
+
// - tests/fixtures (and **/fixtures/polyglot): scanner test inputs are intentionally raw-tagged
|
|
36
|
+
const DEFAULT_PATH_EXCLUDES = Object.freeze([
|
|
37
|
+
'.claude/worktrees',
|
|
38
|
+
'.claude/cap',
|
|
39
|
+
'tests/fixtures',
|
|
40
|
+
'**/fixtures/polyglot',
|
|
41
|
+
'.cap/snapshots',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// @cap-decision(F-085/AC-7) LARGE_DIFF_THRESHOLD is the count above which a destructive batch
|
|
45
|
+
// operation (cap:migrate-tags --apply) requires an extra confirm gate. 500 was chosen by
|
|
46
|
+
// inspecting the realistic worst-case in this repo (~89 legitimate files) and adding a 5x
|
|
47
|
+
// margin. Apply against >500 files is almost always a scope-filter bug, never an intent.
|
|
48
|
+
const LARGE_DIFF_THRESHOLD = 500;
|
|
49
|
+
|
|
50
|
+
// @cap-decision(F-086/AC-2) Bundle-detection thresholds. The line-count budget catches
|
|
51
|
+
// concatenated outputs (Next.js dev bundles routinely hit 5–50k lines, Webpack chunks 10k+);
|
|
52
|
+
// honest source files in this codebase peak around 1600 (cap-tag-scanner.cjs). 5000 is a 3x
|
|
53
|
+
// margin against the largest legitimate file, well below the smallest typical bundle.
|
|
54
|
+
const BUNDLE_LINE_THRESHOLD = 5000;
|
|
55
|
+
|
|
56
|
+
// @cap-decision(F-086/AC-2) Bundle-typical path patterns. RegExps catching shapes that recur
|
|
57
|
+
// across bundlers (Next.js, Webpack, esbuild, Turbopack). Matched against the project-relative
|
|
58
|
+
// POSIX path. Path-pattern check is the cheap pre-filter; the line-count probe is the
|
|
59
|
+
// expensive last resort and only fires when callers explicitly opt in via isBundle().
|
|
60
|
+
const BUNDLE_PATH_PATTERNS = Object.freeze([
|
|
61
|
+
/\/chunks\//, // Next.js / Webpack chunk dir
|
|
62
|
+
/\[root-of-/, // Next.js dev-server bundle naming: [root-of-the-server]
|
|
63
|
+
/__[a-z0-9_]+\._\.js$/i, // Webpack-style hashed bundle: __0p_l47z._.js
|
|
64
|
+
/\.bundle\.[mc]?js$/, // Generic .bundle.js
|
|
65
|
+
/\.min\.[mc]?js$/, // Minified outputs
|
|
66
|
+
/\.chunk\.[mc]?js$/, // .chunk.js
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Gitignore handling
|
|
71
|
+
|
|
72
|
+
// @cap-todo(ac:F-085/AC-2) parseGitignore returns an array of compiled matchers from the
|
|
73
|
+
// project's top-level .gitignore. Negations (`!pattern`) are dropped with a quiet ignore;
|
|
74
|
+
// gitignore precedence rules across multiple files are out of scope for the MVP.
|
|
75
|
+
/**
|
|
76
|
+
* Parse a top-level `.gitignore` file into a list of matcher functions.
|
|
77
|
+
*
|
|
78
|
+
* Each matcher takes (relativePath, isDir) and returns true when the path is ignored.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} projectRoot
|
|
81
|
+
* @returns {Array<(relPath: string, isDir: boolean) => boolean>}
|
|
82
|
+
*/
|
|
83
|
+
function parseGitignore(projectRoot) {
|
|
84
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return [];
|
|
85
|
+
const giPath = path.join(projectRoot, '.gitignore');
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = fs.readFileSync(giPath, 'utf8');
|
|
89
|
+
} catch (_e) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const matchers = [];
|
|
93
|
+
for (const lineRaw of raw.split(/\r?\n/)) {
|
|
94
|
+
const line = lineRaw.trim();
|
|
95
|
+
if (line === '') continue;
|
|
96
|
+
if (line.startsWith('#')) continue;
|
|
97
|
+
if (line.startsWith('!')) continue; // negations not supported in MVP
|
|
98
|
+
matchers.push(_compileGitignorePattern(line));
|
|
99
|
+
}
|
|
100
|
+
return matchers.filter((m) => m !== null);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// @cap-todo(ac:F-085/AC-2) _compileGitignorePattern handles the four common shapes seen in real
|
|
104
|
+
// .gitignore files: `dir/`, `/anchored`, `*.ext`, `path/segment`. Anything more exotic falls
|
|
105
|
+
// back to the literal-substring matcher rather than failing closed.
|
|
106
|
+
function _compileGitignorePattern(pattern) {
|
|
107
|
+
let p = pattern;
|
|
108
|
+
// Trailing slash → "directory" pattern. Per gitignore semantics this matches the directory
|
|
109
|
+
// ITSELF only when isDir, BUT files inside that directory still belong to "ignored content"
|
|
110
|
+
// because their parent dir is ignored. Since our matcher gets called per-path (not as a
|
|
111
|
+
// tree-walk), we accept files when their path starts with `pattern/` — that's the only way
|
|
112
|
+
// a file can be "inside an ignored directory" given a single-path API.
|
|
113
|
+
let dirOnly = false;
|
|
114
|
+
if (p.endsWith('/')) {
|
|
115
|
+
dirOnly = true;
|
|
116
|
+
p = p.slice(0, -1);
|
|
117
|
+
}
|
|
118
|
+
// Leading slash → anchored at repo root
|
|
119
|
+
let anchored = false;
|
|
120
|
+
if (p.startsWith('/')) {
|
|
121
|
+
anchored = true;
|
|
122
|
+
p = p.slice(1);
|
|
123
|
+
}
|
|
124
|
+
// No-glob fast path: literal segment (the dominant case: `node_modules`, `.claude`, `dist`)
|
|
125
|
+
if (!p.includes('*') && !p.includes('?')) {
|
|
126
|
+
return (relPath, isDir) => {
|
|
127
|
+
if (anchored) {
|
|
128
|
+
// Anchored: exact match (must be dir if dirOnly) OR path-prefix (any descendant)
|
|
129
|
+
if (dirOnly) {
|
|
130
|
+
if (relPath === p) return !!isDir;
|
|
131
|
+
return relPath.startsWith(p + '/');
|
|
132
|
+
}
|
|
133
|
+
return relPath === p || relPath.startsWith(p + '/');
|
|
134
|
+
}
|
|
135
|
+
// Unanchored: match anywhere in the tree
|
|
136
|
+
const segments = relPath.split('/');
|
|
137
|
+
if (dirOnly) {
|
|
138
|
+
// For dir-only patterns, accept if any non-leaf segment equals p (descendant case)
|
|
139
|
+
// OR if relPath is exactly p AND it's a directory.
|
|
140
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
141
|
+
if (segments[i] === p) return true;
|
|
142
|
+
}
|
|
143
|
+
return relPath === p && !!isDir;
|
|
144
|
+
}
|
|
145
|
+
// Non-dir-only: any segment match OR exact path-prefix
|
|
146
|
+
if (segments.includes(p)) return true;
|
|
147
|
+
return relPath === p || relPath.startsWith(p + '/');
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// Glob path: compile to regex. ** = any segments, * = within segment, ? = single char.
|
|
151
|
+
const re = _globToRegex(p, { anchored });
|
|
152
|
+
return (relPath, isDir) => {
|
|
153
|
+
if (dirOnly && !isDir) return false;
|
|
154
|
+
if (anchored) return re.test(relPath);
|
|
155
|
+
// Unanchored glob: try against the full path AND any suffix that starts at a segment boundary.
|
|
156
|
+
if (re.test(relPath)) return true;
|
|
157
|
+
const segments = relPath.split('/');
|
|
158
|
+
for (let i = 1; i < segments.length; i++) {
|
|
159
|
+
if (re.test(segments.slice(i).join('/'))) return true;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _globToRegex(glob, opts) {
|
|
166
|
+
let out = opts && opts.anchored ? '^' : '^';
|
|
167
|
+
let i = 0;
|
|
168
|
+
while (i < glob.length) {
|
|
169
|
+
const c = glob[i];
|
|
170
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
171
|
+
out += '.*';
|
|
172
|
+
i += 2;
|
|
173
|
+
if (glob[i] === '/') i += 1;
|
|
174
|
+
} else if (c === '*') {
|
|
175
|
+
out += '[^/]*';
|
|
176
|
+
i += 1;
|
|
177
|
+
} else if (c === '?') {
|
|
178
|
+
out += '[^/]';
|
|
179
|
+
i += 1;
|
|
180
|
+
} else if ('.+()[]{}^$|\\'.includes(c)) {
|
|
181
|
+
out += '\\' + c;
|
|
182
|
+
i += 1;
|
|
183
|
+
} else {
|
|
184
|
+
out += c;
|
|
185
|
+
i += 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
out += '(?:/.*)?$';
|
|
189
|
+
return new RegExp(out);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Path-pattern matching for DEFAULT_PATH_EXCLUDES + user includes/excludes
|
|
194
|
+
|
|
195
|
+
function _matchPathPattern(relPath, pattern) {
|
|
196
|
+
if (typeof pattern !== 'string' || pattern.length === 0) return false;
|
|
197
|
+
// **/foo → match suffix anywhere in the tree
|
|
198
|
+
if (pattern.startsWith('**/')) {
|
|
199
|
+
const tail = pattern.slice(3);
|
|
200
|
+
return relPath === tail || relPath.endsWith('/' + tail) || relPath.startsWith(tail + '/') || relPath.includes('/' + tail + '/');
|
|
201
|
+
}
|
|
202
|
+
// Plain prefix match against project-relative path
|
|
203
|
+
return relPath === pattern || relPath.startsWith(pattern + '/');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Bundle detection (F-086/AC-2)
|
|
208
|
+
|
|
209
|
+
// @cap-todo(ac:F-086/AC-2) isBundle decides whether a file is a generated artefact (Webpack
|
|
210
|
+
// chunk, Next.js dev-bundle, minified output, …). Two probes:
|
|
211
|
+
// - PATH probe (cheap, default): regex match on project-relative path against
|
|
212
|
+
// BUNDLE_PATH_PATTERNS. Catches the typical bundler output naming.
|
|
213
|
+
// - LINE-COUNT probe (expensive, opt-in via deep:true): reads the file and counts lines.
|
|
214
|
+
// Files with > BUNDLE_LINE_THRESHOLD lines are flagged — concatenated bundles routinely
|
|
215
|
+
// exceed this while honest source code in this codebase peaks at ~1600.
|
|
216
|
+
/**
|
|
217
|
+
* @param {string} absPath - absolute or relative path; only the basename + dir-segments matter
|
|
218
|
+
* @param {{ deep?: boolean, lineThreshold?: number }} [opts]
|
|
219
|
+
* deep — if true, also runs the line-count probe (file I/O). Default false.
|
|
220
|
+
* lineThreshold — override BUNDLE_LINE_THRESHOLD.
|
|
221
|
+
* @returns {boolean}
|
|
222
|
+
*/
|
|
223
|
+
function isBundle(absPath, opts) {
|
|
224
|
+
if (typeof absPath !== 'string' || absPath.length === 0) return false;
|
|
225
|
+
const posixPath = absPath.split(path.sep).join('/');
|
|
226
|
+
// Path probe — cheap
|
|
227
|
+
for (const re of BUNDLE_PATH_PATTERNS) {
|
|
228
|
+
if (re.test(posixPath)) return true;
|
|
229
|
+
}
|
|
230
|
+
// Line-count probe — opt-in, performs file I/O
|
|
231
|
+
if (opts && opts.deep) {
|
|
232
|
+
const limit = (opts && typeof opts.lineThreshold === 'number') ? opts.lineThreshold : BUNDLE_LINE_THRESHOLD;
|
|
233
|
+
let raw;
|
|
234
|
+
try {
|
|
235
|
+
raw = fs.readFileSync(absPath, 'utf8');
|
|
236
|
+
} catch (_e) {
|
|
237
|
+
return false; // unreadable → can't decide; default to "not bundle"
|
|
238
|
+
}
|
|
239
|
+
// Quick line count via splitting; for very large files the cost is dominated by readFileSync
|
|
240
|
+
// anyway, so a strchr-style loop wouldn't help meaningfully.
|
|
241
|
+
let lineCount = 1;
|
|
242
|
+
for (let i = 0; i < raw.length; i++) {
|
|
243
|
+
if (raw.charCodeAt(i) === 10 /* \n */) lineCount++;
|
|
244
|
+
// Early exit once we've crossed the threshold — no need to count the rest of a 50k-line bundle.
|
|
245
|
+
if (lineCount > limit) return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Plugin-self-mirror detection (F-085/AC-4)
|
|
253
|
+
|
|
254
|
+
// @cap-todo(ac:F-085/AC-4) Plugin-self-mirror = a directory under cwd that exact-mirrors
|
|
255
|
+
// $HOME/.claude/cap/. Detected by walking up from the scanner's installed location and
|
|
256
|
+
// checking whether projectRoot has the same nested layout. This protects users running CAP
|
|
257
|
+
// from inside a clone of the CAP repo itself, where the mirror is real and writeable.
|
|
258
|
+
function detectPluginMirror(projectRoot) {
|
|
259
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) return null;
|
|
260
|
+
const candidate = path.join(projectRoot, '.claude', 'cap');
|
|
261
|
+
try {
|
|
262
|
+
const st = fs.statSync(candidate);
|
|
263
|
+
if (!st.isDirectory()) return null;
|
|
264
|
+
} catch (_e) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
// Heuristic: if `.claude/cap/bin/` and `.claude/cap/commands/` both exist, this is the
|
|
268
|
+
// plugin-self-mirror layout. (One of those alone could be a coincidence.)
|
|
269
|
+
const binExists = fs.existsSync(path.join(candidate, 'bin'));
|
|
270
|
+
const cmdExists = fs.existsSync(path.join(candidate, 'commands'));
|
|
271
|
+
if (binExists && cmdExists) return path.relative(projectRoot, candidate).split(path.sep).join('/');
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Public: buildScopeFilter
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @typedef {Object} ScopeFilterOptions
|
|
280
|
+
* @property {string[]} [dirExcludes] - Directory basenames to exclude. Defaults to DEFAULT_DIR_EXCLUDES.
|
|
281
|
+
* @property {string[]} [pathExcludes] - Project-relative path patterns to exclude. Defaults to DEFAULT_PATH_EXCLUDES.
|
|
282
|
+
* @property {string[]} [includes] - When non-empty, ONLY paths matching at least one include pattern pass.
|
|
283
|
+
* @property {string[]} [excludes] - User-supplied additional excludes (additive on top of pathExcludes).
|
|
284
|
+
* @property {boolean} [respectGitignore] - Default true. Set false for tests / sandbox runs.
|
|
285
|
+
* @property {boolean} [bundleDetection] - Default true. Set false to skip the path-based bundle filter (F-086/AC-2).
|
|
286
|
+
* @property {boolean} [deepBundleCheck] - Default false. Enables the line-count probe — expensive, opt-in.
|
|
287
|
+
*/
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @typedef {Object} ScopeFilter
|
|
291
|
+
* @property {(absPath: string, isDir: boolean) => boolean} isExcluded
|
|
292
|
+
* @property {(items: Array<string|{file:string}>) => Array<[string, number]>} bucketize
|
|
293
|
+
* @property {string[]} pathExcludes
|
|
294
|
+
* @property {string[]} dirExcludes
|
|
295
|
+
* @property {string|null} pluginMirror
|
|
296
|
+
*/
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Build a scope filter for the given project root.
|
|
300
|
+
*
|
|
301
|
+
* The returned `isExcluded(absPath, isDir)` returns true when the path should be skipped by
|
|
302
|
+
* downstream walkers. It is the single decision point used by both cap-tag-scanner and
|
|
303
|
+
* cap-migrate-tags so their scope semantics never drift apart.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} projectRoot
|
|
306
|
+
* @param {ScopeFilterOptions} [options]
|
|
307
|
+
* @returns {ScopeFilter}
|
|
308
|
+
*/
|
|
309
|
+
function buildScopeFilter(projectRoot, options) {
|
|
310
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
311
|
+
throw new TypeError('projectRoot must be a non-empty string');
|
|
312
|
+
}
|
|
313
|
+
const opts = options || {};
|
|
314
|
+
const dirExcludes = new Set(opts.dirExcludes || DEFAULT_DIR_EXCLUDES);
|
|
315
|
+
const userExcludes = Array.isArray(opts.excludes) ? opts.excludes : [];
|
|
316
|
+
const pathExcludes = [...(opts.pathExcludes || DEFAULT_PATH_EXCLUDES), ...userExcludes];
|
|
317
|
+
const includes = Array.isArray(opts.includes) ? opts.includes : [];
|
|
318
|
+
const respectGitignore = opts.respectGitignore !== false;
|
|
319
|
+
const gitignoreMatchers = respectGitignore ? parseGitignore(projectRoot) : [];
|
|
320
|
+
// @cap-todo(ac:F-086/AC-2) Bundle-detection runs as part of the file-level exclude check.
|
|
321
|
+
// Path-pattern probe is on by default (cheap); deep line-count probe is opt-in (deepBundleCheck).
|
|
322
|
+
const bundleDetection = opts.bundleDetection !== false;
|
|
323
|
+
const deepBundleCheck = opts.deepBundleCheck === true;
|
|
324
|
+
|
|
325
|
+
const pluginMirror = detectPluginMirror(projectRoot);
|
|
326
|
+
// If we detected a plugin mirror, ensure it's in pathExcludes (defense in depth — the
|
|
327
|
+
// gitignore + DEFAULT_PATH_EXCLUDES already cover this, but a user could override both).
|
|
328
|
+
if (pluginMirror && !pathExcludes.includes(pluginMirror)) {
|
|
329
|
+
pathExcludes.push(pluginMirror);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isExcluded(absPath, isDir) {
|
|
333
|
+
const rel = path.relative(projectRoot, absPath).split(path.sep).join('/');
|
|
334
|
+
// A path that's outside projectRoot is, by definition, not in scope.
|
|
335
|
+
if (rel === '' || rel.startsWith('..')) return false;
|
|
336
|
+
const baseName = path.basename(absPath);
|
|
337
|
+
|
|
338
|
+
// 1. Directory-basename fast path (preserves legacy behaviour)
|
|
339
|
+
if (isDir && dirExcludes.has(baseName)) return true;
|
|
340
|
+
|
|
341
|
+
// 2. Path-pattern excludes (project-relative)
|
|
342
|
+
for (const p of pathExcludes) {
|
|
343
|
+
if (_matchPathPattern(rel, p)) return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 3. Gitignore matchers
|
|
347
|
+
for (const m of gitignoreMatchers) {
|
|
348
|
+
if (m(rel, !!isDir)) return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 4. Bundle-detection (F-086/AC-2): only for files, not directories.
|
|
352
|
+
if (!isDir && bundleDetection) {
|
|
353
|
+
if (isBundle(absPath, { deep: deepBundleCheck })) return true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 5. Includes are a positive filter: when set, only matches pass
|
|
357
|
+
if (includes.length > 0) {
|
|
358
|
+
let matched = false;
|
|
359
|
+
for (const p of includes) {
|
|
360
|
+
if (_matchPathPattern(rel, p)) { matched = true; break; }
|
|
361
|
+
}
|
|
362
|
+
if (!matched) return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function bucketize(items) {
|
|
369
|
+
const buckets = new Map();
|
|
370
|
+
for (const it of items) {
|
|
371
|
+
const p = typeof it === 'string' ? it : (it && typeof it.file === 'string' ? it.file : '');
|
|
372
|
+
if (p === '') continue;
|
|
373
|
+
const top = p.split('/').slice(0, 2).join('/');
|
|
374
|
+
buckets.set(top, (buckets.get(top) || 0) + 1);
|
|
375
|
+
}
|
|
376
|
+
return [...buckets.entries()].sort((a, b) => b[1] - a[1]);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
isExcluded,
|
|
381
|
+
bucketize,
|
|
382
|
+
pathExcludes,
|
|
383
|
+
dirExcludes: [...dirExcludes],
|
|
384
|
+
pluginMirror,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = {
|
|
389
|
+
buildScopeFilter,
|
|
390
|
+
parseGitignore,
|
|
391
|
+
detectPluginMirror,
|
|
392
|
+
isBundle,
|
|
393
|
+
DEFAULT_DIR_EXCLUDES,
|
|
394
|
+
DEFAULT_PATH_EXCLUDES,
|
|
395
|
+
BUNDLE_LINE_THRESHOLD,
|
|
396
|
+
BUNDLE_PATH_PATTERNS,
|
|
397
|
+
LARGE_DIFF_THRESHOLD,
|
|
398
|
+
// Internal helpers exported for unit tests.
|
|
399
|
+
_matchPathPattern,
|
|
400
|
+
_compileGitignorePattern,
|
|
401
|
+
_globToRegex,
|
|
402
|
+
};
|