@doxbrix/doxloop 0.1.5 → 0.2.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/CHANGELOG.md +1145 -0
- package/README.md +422 -227
- package/assets/doxbrix-preview.css +77 -15
- package/contracts/agent-events-v1.schema.json +1 -0
- package/contracts/coverage-v1.schema.json +59 -0
- package/contracts/drift-v1.schema.json +1 -0
- package/contracts/evaluation-v1.schema.json +62 -0
- package/contracts/quality-codes-v1.json +1 -0
- package/contracts/quality-report-v1.schema.json +1 -0
- package/contracts/validation-v1.schema.json +1 -0
- package/dist/agent-failure.d.ts +36 -0
- package/dist/agent-failure.js +98 -0
- package/dist/agent-isolation.d.ts +17 -0
- package/dist/agent-isolation.js +94 -0
- package/dist/agent-log.d.ts +197 -0
- package/dist/agent-log.js +1094 -0
- package/dist/agent-process.d.ts +63 -0
- package/dist/agent-process.js +128 -0
- package/dist/agent-reply.d.ts +47 -0
- package/dist/agent-reply.js +315 -0
- package/dist/agents.d.ts +24 -5
- package/dist/agents.js +60 -9
- package/dist/api-coverage.d.ts +11 -0
- package/dist/api-coverage.js +72 -0
- package/dist/application-probe.d.ts +28 -0
- package/dist/application-probe.js +93 -0
- package/dist/application-url.d.ts +14 -0
- package/dist/application-url.js +26 -0
- package/dist/args.js +15 -0
- package/dist/artifact-deploy.d.ts +9 -0
- package/dist/artifact-deploy.js +28 -8
- package/dist/assets.d.ts +72 -0
- package/dist/assets.js +382 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +38 -9
- package/dist/author.d.ts +206 -4
- package/dist/author.js +1650 -52
- package/dist/authoring-batches.d.ts +190 -0
- package/dist/authoring-batches.js +489 -0
- package/dist/authoring-postpass.d.ts +79 -0
- package/dist/authoring-postpass.js +1289 -0
- package/dist/authoring-progress.d.ts +95 -0
- package/dist/authoring-progress.js +269 -0
- package/dist/autosync.d.ts +56 -0
- package/dist/autosync.js +442 -0
- package/dist/batch-artifacts.d.ts +81 -0
- package/dist/batch-artifacts.js +412 -0
- package/dist/batch-limits.d.ts +37 -0
- package/dist/batch-limits.js +80 -0
- package/dist/branding.d.ts +41 -0
- package/dist/branding.js +229 -0
- package/dist/bundle-upload.d.ts +26 -0
- package/dist/bundle-upload.js +99 -0
- package/dist/capture-auth.d.ts +95 -0
- package/dist/capture-auth.js +204 -0
- package/dist/capture.d.ts +48 -0
- package/dist/capture.js +20 -1
- package/dist/cli.js +839 -32
- package/dist/content-links.d.ts +10 -0
- package/dist/content-links.js +49 -0
- package/dist/contract-validation.d.ts +4 -0
- package/dist/contract-validation.js +25 -0
- package/dist/coverage-actions.d.ts +8 -0
- package/dist/coverage-actions.js +71 -0
- package/dist/coverage-resolutions.d.ts +16 -0
- package/dist/coverage-resolutions.js +58 -0
- package/dist/db.d.ts +37 -0
- package/dist/db.js +288 -0
- package/dist/demo.d.ts +12 -0
- package/dist/demo.js +122 -0
- package/dist/deploy-credentials.d.ts +7 -0
- package/dist/deploy-credentials.js +76 -0
- package/dist/deploy-targets/github-pages.d.ts +3 -0
- package/dist/deploy-targets/github-pages.js +100 -0
- package/dist/deploy-targets/index.d.ts +8 -0
- package/dist/deploy-targets/index.js +78 -0
- package/dist/deploy-targets/netlify.d.ts +3 -0
- package/dist/deploy-targets/netlify.js +49 -0
- package/dist/deploy-targets/types.d.ts +32 -0
- package/dist/deploy-targets/types.js +2 -0
- package/dist/deploy-targets/vercel.d.ts +3 -0
- package/dist/deploy-targets/vercel.js +95 -0
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +87 -13
- package/dist/deterministic-capture.d.ts +90 -0
- package/dist/deterministic-capture.js +435 -0
- package/dist/direct-edit.d.ts +40 -0
- package/dist/direct-edit.js +159 -0
- package/dist/docs-crawl.d.ts +86 -0
- package/dist/docs-crawl.js +536 -0
- package/dist/docs-site.d.ts +49 -0
- package/dist/docs-site.js +233 -0
- package/dist/doctor.js +8 -0
- package/dist/documentation-collections.d.ts +23 -0
- package/dist/documentation-collections.js +191 -0
- package/dist/documentation-plan.d.ts +253 -0
- package/dist/documentation-plan.js +2563 -0
- package/dist/doxbrix-build.d.ts +19 -0
- package/dist/doxbrix-build.js +167 -0
- package/dist/doxbrix-markdown.d.ts +9 -0
- package/dist/doxbrix-markdown.js +115 -14
- package/dist/drift.d.ts +10 -0
- package/dist/drift.js +164 -0
- package/dist/evaluation.d.ts +46 -0
- package/dist/evaluation.js +113 -0
- package/dist/evidence-pack.d.ts +47 -0
- package/dist/evidence-pack.js +358 -0
- package/dist/evidence.d.ts +25 -0
- package/dist/evidence.js +175 -0
- package/dist/fs.d.ts +8 -2
- package/dist/fs.js +41 -11
- package/dist/generator-api.d.ts +89 -0
- package/dist/generator-preflight.d.ts +27 -0
- package/dist/generator-preflight.js +105 -0
- package/dist/generator-runtime.d.ts +7 -0
- package/dist/generator-runtime.js +17 -1
- package/dist/generators.d.ts +24 -2
- package/dist/generators.js +54 -1
- package/dist/git-delivery.d.ts +17 -0
- package/dist/git-delivery.js +123 -0
- package/dist/globs.d.ts +16 -0
- package/dist/globs.js +65 -0
- package/dist/glossary.d.ts +26 -0
- package/dist/glossary.js +179 -0
- package/dist/history.d.ts +106 -0
- package/dist/history.js +600 -0
- package/dist/html-markdown.d.ts +46 -0
- package/dist/html-markdown.js +423 -0
- package/dist/interactive.js +3 -0
- package/dist/job-events.d.ts +74 -0
- package/dist/job-events.js +377 -0
- package/dist/keep-awake.d.ts +50 -0
- package/dist/keep-awake.js +123 -0
- package/dist/local-source-snapshot.d.ts +20 -0
- package/dist/local-source-snapshot.js +61 -0
- package/dist/mintlify-detect.d.ts +3 -0
- package/dist/mintlify-detect.js +18 -0
- package/dist/mintlify-import.d.ts +75 -0
- package/dist/mintlify-import.js +190 -0
- package/dist/navigation.d.ts +98 -0
- package/dist/navigation.js +310 -0
- package/dist/openapi.d.ts +60 -0
- package/dist/openapi.js +439 -0
- package/dist/page-editor-bridge.d.ts +3 -0
- package/dist/page-editor-bridge.js +109 -0
- package/dist/page-editor-preview.d.ts +10 -0
- package/dist/page-editor-preview.js +55 -0
- package/dist/page-extension.d.ts +9 -0
- package/dist/page-extension.js +15 -0
- package/dist/page-metadata.d.ts +28 -0
- package/dist/page-metadata.js +166 -0
- package/dist/page-operations.d.ts +34 -0
- package/dist/page-operations.js +215 -0
- package/dist/page-routes.d.ts +4 -0
- package/dist/page-routes.js +61 -0
- package/dist/pages.d.ts +20 -0
- package/dist/pages.js +184 -0
- package/dist/plan-generator.d.ts +3 -0
- package/dist/plan-generator.js +21 -0
- package/dist/plan-navigation.d.ts +11 -0
- package/dist/plan-navigation.js +30 -0
- package/dist/planning-captures.d.ts +20 -0
- package/dist/planning-captures.js +143 -0
- package/dist/planning-research.d.ts +135 -0
- package/dist/planning-research.js +472 -0
- package/dist/planning-triage.d.ts +23 -0
- package/dist/planning-triage.js +131 -0
- package/dist/preview.d.ts +24 -0
- package/dist/preview.js +280 -29
- package/dist/project-detect.d.ts +36 -0
- package/dist/project-detect.js +251 -0
- package/dist/project-import.d.ts +54 -0
- package/dist/project-import.js +157 -0
- package/dist/project-lock.d.ts +6 -0
- package/dist/project-lock.js +96 -0
- package/dist/project-registry.d.ts +25 -0
- package/dist/project-registry.js +79 -0
- package/dist/project.d.ts +35 -3
- package/dist/project.js +388 -38
- package/dist/prompts.d.ts +9 -0
- package/dist/prompts.js +25 -0
- package/dist/proposal-replay.d.ts +32 -0
- package/dist/proposal-replay.js +99 -0
- package/dist/quality-claims.d.ts +8 -0
- package/dist/quality-claims.js +168 -0
- package/dist/quality-config.d.ts +5 -0
- package/dist/quality-config.js +84 -0
- package/dist/quality-contract.d.ts +37 -0
- package/dist/quality-contract.js +46 -0
- package/dist/quality-examples.d.ts +4 -0
- package/dist/quality-examples.js +233 -0
- package/dist/quality-gates.d.ts +16 -0
- package/dist/quality-gates.js +192 -0
- package/dist/quality-links.d.ts +7 -0
- package/dist/quality-links.js +149 -0
- package/dist/quality-lint.d.ts +6 -0
- package/dist/quality-lint.js +124 -0
- package/dist/quality-rendered.d.ts +11 -0
- package/dist/quality-rendered.js +222 -0
- package/dist/quality-schema.d.ts +3 -0
- package/dist/quality-schema.js +71 -0
- package/dist/release-notes.d.ts +44 -0
- package/dist/release-notes.js +183 -0
- package/dist/remote-monitor.d.ts +16 -0
- package/dist/remote-monitor.js +74 -0
- package/dist/remote-source.d.ts +34 -0
- package/dist/remote-source.js +426 -0
- package/dist/review-diff.d.ts +82 -0
- package/dist/review-diff.js +400 -0
- package/dist/review-learning.d.ts +11 -0
- package/dist/review-learning.js +60 -0
- package/dist/review-render.d.ts +38 -0
- package/dist/review-render.js +224 -0
- package/dist/review-report.d.ts +9 -0
- package/dist/review-report.js +89 -0
- package/dist/review-ui.d.ts +14 -0
- package/dist/review-ui.js +1248 -0
- package/dist/schedule.d.ts +78 -0
- package/dist/schedule.js +480 -0
- package/dist/screen-capture-provider.d.ts +52 -0
- package/dist/screen-capture-provider.js +218 -0
- package/dist/screenshot-workflow.d.ts +167 -0
- package/dist/screenshot-workflow.js +1237 -0
- package/dist/settings.d.ts +1 -1
- package/dist/settings.js +95 -7
- package/dist/site-export.d.ts +18 -0
- package/dist/site-export.js +87 -0
- package/dist/source-connectors.d.ts +33 -0
- package/dist/source-connectors.js +268 -0
- package/dist/source-discovery.d.ts +132 -0
- package/dist/source-discovery.js +823 -0
- package/dist/source-intelligence.d.ts +9 -0
- package/dist/source-intelligence.js +306 -0
- package/dist/sync-review.d.ts +28 -0
- package/dist/sync-review.js +264 -0
- package/dist/sync-runs.d.ts +192 -0
- package/dist/sync-runs.js +2244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +298 -32
- package/dist/text-diff.d.ts +9 -0
- package/dist/text-diff.js +59 -0
- package/dist/types.d.ts +946 -1
- package/dist/ui/assets/doxloop-logo-light-De7Nx7j7.png +0 -0
- package/dist/ui/assets/index-BHBYU2aG.css +1 -0
- package/dist/ui/assets/index-Cq3RPQiC.js +33 -0
- package/dist/ui/index.html +18 -0
- package/dist/ui-server.d.ts +83 -0
- package/dist/ui-server.js +3532 -0
- package/dist/usage-budget.d.ts +28 -0
- package/dist/usage-budget.js +90 -0
- package/dist/validation.d.ts +25 -1
- package/dist/validation.js +312 -26
- package/dist/workspace-tools.d.ts +54 -0
- package/dist/workspace-tools.js +123 -0
- package/docs/agent-compatibility.md +49 -32
- package/docs/ci-and-automation.md +105 -66
- package/docs/doxbrix-http-api.md +8 -1
- package/docs/existing-documentation.md +80 -0
- package/docs/generation-performance.md +108 -0
- package/docs/generator-authoring.md +68 -5
- package/docs/generator-selection.md +50 -13
- package/docs/mintlify-import.md +71 -0
- package/docs/openapi-security.md +25 -0
- package/docs/project-format.md +331 -36
- package/docs/release-quality.md +158 -0
- package/docs/releasing.md +72 -0
- package/docs/review-workflows.md +51 -0
- package/docs/security-model.md +102 -39
- package/docs/troubleshooting.md +226 -93
- package/package.json +46 -15
- package/scripts/test-auto-screenshot.mjs +172 -0
- package/skills/doxloop-authoring/SKILL.md +242 -361
- package/skills/doxloop-authoring/references/existing-documentation.md +94 -0
- package/skills/doxloop-authoring/references/navigation-architecture.md +18 -7
- package/skills/doxloop-authoring/references/page-depth.md +169 -0
- package/skills/doxloop-authoring/references/project-format.md +106 -7
- package/skills/doxloop-authoring/references/quality.md +10 -0
- package/skills/doxloop-authoring/references/screenshot-manifest.md +113 -0
- package/skills/doxloop-authoring/references/screenshots.md +149 -235
- package/skills/doxloop-authoring/references/workflows.md +76 -0
- package/skills/doxloop-doxbrix/SKILL.md +38 -22
- package/skills/doxloop-doxbrix/references/api-endpoints.md +15 -14
- package/skills/doxloop-doxbrix/references/components.md +30 -3
- package/skills/doxloop-doxbrix/references/manifest.md +3 -2
- package/vendor/doxbrix-import/LICENSE +202 -0
- package/vendor/doxbrix-import/README.md +17 -0
- package/vendor/doxbrix-import/UPSTREAM.json +47 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.d.ts +13 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.js +83 -0
- package/vendor/doxbrix-import/dist/docs/import.d.ts +21 -0
- package/vendor/doxbrix-import/dist/docs/import.js +147 -0
- package/vendor/doxbrix-import/dist/docs/manifest.d.ts +163 -0
- package/vendor/doxbrix-import/dist/docs/manifest.js +64 -0
- package/vendor/doxbrix-import/dist/docs/project.d.ts +25 -0
- package/vendor/doxbrix-import/dist/docs/project.js +77 -0
- package/vendor/doxbrix-import/dist/docs/starter.d.ts +4 -0
- package/vendor/doxbrix-import/dist/docs/starter.js +11 -0
- package/vendor/doxbrix-import/dist/importer.d.ts +227 -0
- package/vendor/doxbrix-import/dist/importer.js +1567 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.d.ts +37 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.js +305 -0
- package/vendor/doxbrix-import/dist/safe-path.d.ts +9 -0
- package/vendor/doxbrix-import/dist/safe-path.js +47 -0
- package/dist/agents.d.ts.map +0 -1
- package/dist/agents.js.map +0 -1
- package/dist/args.d.ts.map +0 -1
- package/dist/args.js.map +0 -1
- package/dist/artifact-deploy.d.ts.map +0 -1
- package/dist/artifact-deploy.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/author.d.ts.map +0 -1
- package/dist/author.js.map +0 -1
- package/dist/capture.d.ts.map +0 -1
- package/dist/capture.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/deploy.d.ts.map +0 -1
- package/dist/deploy.js.map +0 -1
- package/dist/deployment-visibility.d.ts.map +0 -1
- package/dist/deployment-visibility.js.map +0 -1
- package/dist/doctor.d.ts.map +0 -1
- package/dist/doctor.js.map +0 -1
- package/dist/doxbrix-markdown.d.ts.map +0 -1
- package/dist/doxbrix-markdown.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/fs.d.ts.map +0 -1
- package/dist/fs.js.map +0 -1
- package/dist/generator-api.d.ts.map +0 -1
- package/dist/generator-api.js.map +0 -1
- package/dist/generator-manager.d.ts.map +0 -1
- package/dist/generator-manager.js.map +0 -1
- package/dist/generator-runtime.d.ts.map +0 -1
- package/dist/generator-runtime.js.map +0 -1
- package/dist/generators.d.ts.map +0 -1
- package/dist/generators.js.map +0 -1
- package/dist/interactive.d.ts.map +0 -1
- package/dist/interactive.js.map +0 -1
- package/dist/preview.d.ts.map +0 -1
- package/dist/preview.js.map +0 -1
- package/dist/progress.d.ts.map +0 -1
- package/dist/progress.js.map +0 -1
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js.map +0 -1
- package/dist/prompts.d.ts.map +0 -1
- package/dist/prompts.js.map +0 -1
- package/dist/settings.d.ts.map +0 -1
- package/dist/settings.js.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { extname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { matchesAnyGlob } from './globs.js';
|
|
5
|
+
import { readDocsSiteManifest } from './docs-site.js';
|
|
6
|
+
import { loadOpenApiSource, parseOpenApi } from './openapi.js';
|
|
7
|
+
import { loadPages, loadProject, sourceKind } from './project.js';
|
|
8
|
+
import { sourceSnapshotFingerprints } from './sync.js';
|
|
9
|
+
const DISCOVERY_SCHEMA_VERSION = 1;
|
|
10
|
+
/** Bump when inventory rules change so cached inventories are rebuilt. */
|
|
11
|
+
const DISCOVERY_RULES_VERSION = 6;
|
|
12
|
+
const DISCOVERY_CACHE_DIRECTORY = join('.doxloop', 'cache', 'discovery');
|
|
13
|
+
/**
|
|
14
|
+
* Inspectable text files per source. The cap applies after product code is
|
|
15
|
+
* placed first, so a repository whose `public/` folder holds hundreds of
|
|
16
|
+
* icons still has its `src/` routes, screens, and configuration inventoried.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_FILES_PER_SOURCE = 2500;
|
|
19
|
+
/**
|
|
20
|
+
* Inventory rows sent to the planner. Public-surface kinds are kept ahead of
|
|
21
|
+
* file-level kinds so a large repository's documentation and test files do not
|
|
22
|
+
* crowd out its commands, routes, and configuration.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_SIGNALS_PER_SOURCE = 800;
|
|
25
|
+
const KIND_PRIORITY = ['package', 'command', 'route', 'operation', 'export', 'configuration', 'authentication', 'authorization', 'error', 'event', 'integration', 'example', 'documentation', 'test', 'asset'];
|
|
26
|
+
const MAX_FILE_BYTES = 128 * 1024;
|
|
27
|
+
const MAX_TEXT_BYTES_PER_SOURCE = 12 * 1024 * 1024;
|
|
28
|
+
/**
|
|
29
|
+
* Inspection order. Product code carries the public surface; prose, examples,
|
|
30
|
+
* and tests are supporting evidence; assets never produce a signal worth a
|
|
31
|
+
* reader's attention, so they are inventoried last and never inspected.
|
|
32
|
+
*/
|
|
33
|
+
const ROLE_PRIORITY = { code: 0, other: 1, infrastructure: 2, documentation: 3, example: 4, test: 5, fixture: 6, asset: 7 };
|
|
34
|
+
/** English UI message catalogs that hold the strings a reader actually sees. */
|
|
35
|
+
const LABEL_CATALOG_PATH = /(^|\/)(?:lang|langs|locales?|i18n|intl\/messages|intl|translations?|messages|l10n)\/(?:en|en[-_][A-Za-z]{2})\.(?:json|ya?ml)$/i;
|
|
36
|
+
const TEXT_EXTENSIONS = new Set([
|
|
37
|
+
'.c', '.cc', '.cpp', '.cs', '.go', '.graphql', '.gql', '.h', '.hpp', '.html',
|
|
38
|
+
'.java', '.js', '.json', '.jsx', '.kt', '.md', '.mdx', '.php', '.proto',
|
|
39
|
+
'.py', '.rb', '.rs', '.rst', '.sh', '.sql', '.swift', '.toml', '.ts', '.tsx',
|
|
40
|
+
'.vue', '.xml', '.yaml', '.yml',
|
|
41
|
+
]);
|
|
42
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
43
|
+
'.git', '.hg', '.svn', '.turbo', '.next', '.nuxt', '.output', '.cache',
|
|
44
|
+
'build', 'coverage', 'dist', 'node_modules', 'target', 'vendor',
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Build a bounded, deterministic public-surface inventory. The cache key is
|
|
48
|
+
* derived from configured sources, safe source fingerprints, and discovery
|
|
49
|
+
* rules so unchanged sources do not consume another planning pass.
|
|
50
|
+
*/
|
|
51
|
+
export async function discoverDocumentationSources(root) {
|
|
52
|
+
const project = await loadProject(root);
|
|
53
|
+
const syncFingerprints = await sourceSnapshotFingerprints(root, project.sources);
|
|
54
|
+
const fingerprints = {};
|
|
55
|
+
for (const source of project.sources) {
|
|
56
|
+
fingerprints[source.name] = sourceKind(source) === 'directory'
|
|
57
|
+
? await safeDirectoryFingerprint(resolve(root, source.path), project.sync.ignore)
|
|
58
|
+
: syncFingerprints[source.name] ?? null;
|
|
59
|
+
}
|
|
60
|
+
const cacheKey = createHash('sha256').update(JSON.stringify({
|
|
61
|
+
schemaVersion: DISCOVERY_SCHEMA_VERSION,
|
|
62
|
+
rulesVersion: DISCOVERY_RULES_VERSION,
|
|
63
|
+
sources: project.sources,
|
|
64
|
+
fingerprints,
|
|
65
|
+
ignore: project.sync.ignore,
|
|
66
|
+
})).digest('hex');
|
|
67
|
+
const cachePath = join(root, DISCOVERY_CACHE_DIRECTORY, `${cacheKey}.json`);
|
|
68
|
+
try {
|
|
69
|
+
const cached = JSON.parse(await readFile(cachePath, 'utf8'));
|
|
70
|
+
if (isDiscoveryInventory(cached, cacheKey))
|
|
71
|
+
return { inventory: cached, cacheHit: true };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// A missing or malformed cache is replaced by deterministic discovery.
|
|
75
|
+
}
|
|
76
|
+
const sources = [];
|
|
77
|
+
for (const source of project.sources) {
|
|
78
|
+
sources.push(await discoverSource(root, project, source, fingerprints[source.name] ?? null));
|
|
79
|
+
}
|
|
80
|
+
const pages = await safeExistingPages(root, project);
|
|
81
|
+
const existingPages = pages.map((page) => portable(relative(root, page)));
|
|
82
|
+
const navigationFiles = await existingNavigationFiles(root, project);
|
|
83
|
+
// Pages of an existing documentation site describe the product second-hand.
|
|
84
|
+
// They are listed so the planner can account for every one of them, but they
|
|
85
|
+
// are not public product surface and must not inflate the suggested size.
|
|
86
|
+
const publicSignals = sources.filter((source) => source.kind !== 'docs-site').reduce((total, source) => total + source.evidence.length, 0);
|
|
87
|
+
const inventory = {
|
|
88
|
+
schemaVersion: DISCOVERY_SCHEMA_VERSION,
|
|
89
|
+
cacheKey,
|
|
90
|
+
generatedAt: new Date().toISOString(),
|
|
91
|
+
sources,
|
|
92
|
+
existingPages,
|
|
93
|
+
navigationFiles,
|
|
94
|
+
totals: {
|
|
95
|
+
sources: sources.length,
|
|
96
|
+
filesScanned: sources.reduce((total, source) => total + source.filesScanned, 0),
|
|
97
|
+
publicSignals,
|
|
98
|
+
existingPages: existingPages.length,
|
|
99
|
+
},
|
|
100
|
+
suggestedPages: suggestedPageCounts(publicSignals, existingPages.length),
|
|
101
|
+
};
|
|
102
|
+
await mkdir(join(root, DISCOVERY_CACHE_DIRECTORY), { recursive: true });
|
|
103
|
+
await writeFile(cachePath, `${JSON.stringify(inventory, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
104
|
+
return { inventory, cacheHit: false };
|
|
105
|
+
}
|
|
106
|
+
/** Compact JSON for prompt injection; the on-disk cache stays pretty-printed. */
|
|
107
|
+
/**
|
|
108
|
+
* Evidence kinds whose rows are regex hits on code lines (`const oauth =
|
|
109
|
+
* require('./oauth.js')`, `update:modelValue`). Hundreds of them tell the
|
|
110
|
+
* planner nothing a file list does not, so they are summarised per file.
|
|
111
|
+
*/
|
|
112
|
+
const SUMMARISED_EVIDENCE_KINDS = new Set(['authentication', 'authorization', 'event', 'integration']);
|
|
113
|
+
const MAXIMUM_LISTED_ROWS_PER_KIND = 160;
|
|
114
|
+
const MAXIMUM_SUMMARISED_FILES_PER_KIND = 24;
|
|
115
|
+
/**
|
|
116
|
+
* The inventory as the planner reads it: grouped text instead of one JSON
|
|
117
|
+
* line. The same JSON for a mid-sized product ran to 120k characters and was
|
|
118
|
+
* re-sent on every planning turn; grouping rows by kind and file keeps every
|
|
119
|
+
* citation (source, path, kind, label, line) at a fraction of the size.
|
|
120
|
+
*/
|
|
121
|
+
export function formatDiscoveryInventory(inventory) {
|
|
122
|
+
const lines = [];
|
|
123
|
+
const { totals, suggestedPages } = inventory;
|
|
124
|
+
lines.push(`Inventory ${inventory.cacheKey.slice(0, 12)} generated ${inventory.generatedAt}: ${totals.sources} source${totals.sources === 1 ? '' : 's'}, ${totals.filesScanned} files scanned, ${totals.publicSignals} public signals, ${totals.existingPages} existing documentation page${totals.existingPages === 1 ? '' : 's'}. Suggested page counts: starter ${suggestedPages.starter}, standard ${suggestedPages.standard}, comprehensive ${suggestedPages.comprehensive}.`);
|
|
125
|
+
if (inventory.existingPages.length > 0)
|
|
126
|
+
lines.push(`Existing documentation pages: ${inventory.existingPages.join(', ')}`);
|
|
127
|
+
if (inventory.navigationFiles.length > 0)
|
|
128
|
+
lines.push(`Navigation files: ${inventory.navigationFiles.join(', ')}`);
|
|
129
|
+
for (const source of inventory.sources) {
|
|
130
|
+
lines.push('');
|
|
131
|
+
const coverage = source.truncated
|
|
132
|
+
? `${source.filesScanned} of ${source.filesAvailable} files scanned (partial)`
|
|
133
|
+
: `${source.filesScanned} file${source.filesScanned === 1 ? '' : 's'} scanned`;
|
|
134
|
+
const facts = [
|
|
135
|
+
source.languages.length > 0 ? source.languages.join(', ') : undefined,
|
|
136
|
+
source.packageNames.length > 0 ? `packages ${source.packageNames.join(', ')}` : undefined,
|
|
137
|
+
coverage,
|
|
138
|
+
source.revision ? `revision ${source.revision.slice(0, 12)}` : undefined,
|
|
139
|
+
].filter((item) => Boolean(item));
|
|
140
|
+
lines.push(`Source "${source.name}" (${source.kind}, ${source.location}): ${facts.join('; ')}. Cite rows as source "${source.name}" with the path, kind, label, and line shown.`);
|
|
141
|
+
if (source.uiLabelCatalogs.length > 0)
|
|
142
|
+
lines.push(` UI label catalogs: ${source.uiLabelCatalogs.join(', ')}`);
|
|
143
|
+
for (const warning of source.warnings)
|
|
144
|
+
lines.push(` Warning: ${warning}`);
|
|
145
|
+
const byKind = new Map();
|
|
146
|
+
for (const row of source.evidence) {
|
|
147
|
+
const rows = byKind.get(row.kind) ?? [];
|
|
148
|
+
rows.push(row);
|
|
149
|
+
byKind.set(row.kind, rows);
|
|
150
|
+
}
|
|
151
|
+
for (const [kind, rows] of byKind) {
|
|
152
|
+
lines.push(SUMMARISED_EVIDENCE_KINDS.has(kind) ? summarisedKindLine(kind, rows) : listedKindLines(kind, rows));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return lines.join('\n');
|
|
156
|
+
}
|
|
157
|
+
function summarisedKindLine(kind, rows) {
|
|
158
|
+
const counts = new Map();
|
|
159
|
+
for (const row of rows)
|
|
160
|
+
counts.set(row.path, (counts.get(row.path) ?? 0) + 1);
|
|
161
|
+
const files = [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
162
|
+
const shown = files.slice(0, MAXIMUM_SUMMARISED_FILES_PER_KIND).map(([path, count]) => (count === 1 ? path : `${path} (${count})`));
|
|
163
|
+
const rest = files.length - shown.length;
|
|
164
|
+
return ` ${kind} (${rows.length} matches in ${files.length} file${files.length === 1 ? '' : 's'}; read the files for the behavior): ${shown.join(', ')}${rest > 0 ? `, +${rest} more files` : ''}`;
|
|
165
|
+
}
|
|
166
|
+
function listedKindLines(kind, rows) {
|
|
167
|
+
const byPath = new Map();
|
|
168
|
+
let listed = 0;
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
if (listed >= MAXIMUM_LISTED_ROWS_PER_KIND)
|
|
171
|
+
break;
|
|
172
|
+
const group = byPath.get(row.path) ?? [];
|
|
173
|
+
group.push(row);
|
|
174
|
+
byPath.set(row.path, group);
|
|
175
|
+
listed += 1;
|
|
176
|
+
}
|
|
177
|
+
const groups = [...byPath.entries()].map(([path, group]) => `${path}: ${group.map((row) => (row.line === undefined ? row.label : `${row.label} @${row.line}`)).join('; ')}`);
|
|
178
|
+
const rest = rows.length - listed;
|
|
179
|
+
const header = ` ${kind} (${rows.length})${rest > 0 ? `, first ${listed} shown, +${rest} more` : ''}:`;
|
|
180
|
+
return `${header}\n${groups.map((group) => ` ${group}`).join('\n')}`;
|
|
181
|
+
}
|
|
182
|
+
async function discoverSource(root, project, source, revision) {
|
|
183
|
+
if (sourceKind(source) === 'openapi')
|
|
184
|
+
return discoverOpenApiSource(root, source, revision);
|
|
185
|
+
if (sourceKind(source) === 'docs-site')
|
|
186
|
+
return discoverDocsSiteSource(root, source, revision);
|
|
187
|
+
const sourceRoot = resolve(root, source.path);
|
|
188
|
+
const files = await safeSourceFiles(sourceRoot, project.sync.ignore);
|
|
189
|
+
const evidence = [];
|
|
190
|
+
const languages = new Set();
|
|
191
|
+
const packageNames = new Set();
|
|
192
|
+
const warnings = [];
|
|
193
|
+
const uiLabelCatalogs = [];
|
|
194
|
+
let textBytes = 0;
|
|
195
|
+
let filesScanned = 0;
|
|
196
|
+
// Only text files can yield a signal, and product code must be read before
|
|
197
|
+
// anything else: an alphabetical cap once spent the whole budget on a
|
|
198
|
+
// `public/` folder of icons and never opened `src/`.
|
|
199
|
+
const inspectable = files
|
|
200
|
+
.map((path) => ({ path, sourcePath: portable(relative(sourceRoot, path)) }))
|
|
201
|
+
.filter(({ path, sourcePath }) => TEXT_EXTENSIONS.has(extname(path).toLowerCase()) || sourcePath.toLowerCase().endsWith('.env.example'))
|
|
202
|
+
.map((entry) => ({ ...entry, role: classifySourceFile(entry.sourcePath) }))
|
|
203
|
+
.filter((entry) => entry.role !== 'asset' || LABEL_CATALOG_PATH.test(entry.sourcePath))
|
|
204
|
+
.sort((left, right) => ROLE_PRIORITY[left.role] - ROLE_PRIORITY[right.role] || left.sourcePath.localeCompare(right.sourcePath));
|
|
205
|
+
const candidates = inspectable.slice(0, MAX_FILES_PER_SOURCE);
|
|
206
|
+
// Package entry points decide which exports count as public surface, and a
|
|
207
|
+
// package manifest can sort after the code it describes, so read them first.
|
|
208
|
+
const packageFolders = [];
|
|
209
|
+
const entryPoints = await packageEntryPoints(sourceRoot, candidates.map((entry) => entry.path), packageFolders);
|
|
210
|
+
const noEntryPoints = new Set();
|
|
211
|
+
let budgetExhausted = false;
|
|
212
|
+
for (const { path, sourcePath } of candidates) {
|
|
213
|
+
const extension = extname(path).toLowerCase();
|
|
214
|
+
if (LABEL_CATALOG_PATH.test(sourcePath))
|
|
215
|
+
uiLabelCatalogs.push(sourcePath);
|
|
216
|
+
const size = (await lstat(path)).size;
|
|
217
|
+
if (size > MAX_FILE_BYTES)
|
|
218
|
+
continue;
|
|
219
|
+
if (textBytes + size > MAX_TEXT_BYTES_PER_SOURCE) {
|
|
220
|
+
budgetExhausted = true;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
let content;
|
|
224
|
+
try {
|
|
225
|
+
content = await readFile(path, 'utf8');
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
textBytes += size;
|
|
231
|
+
filesScanned += 1;
|
|
232
|
+
languages.add(languageForExtension(extension));
|
|
233
|
+
inspectTextFile(source.name, sourcePath, content, evidence, packageNames, exportsArePublic(sourcePath, packageFolders) ? entryPoints : noEntryPoints);
|
|
234
|
+
}
|
|
235
|
+
evidence.push(...await repositoryOpenApiEvidence(source.name, inspectable, warnings));
|
|
236
|
+
const truncated = inspectable.length > MAX_FILES_PER_SOURCE || budgetExhausted;
|
|
237
|
+
if (inspectable.length > MAX_FILES_PER_SOURCE)
|
|
238
|
+
warnings.push(`Inventory inspected ${filesScanned} of ${inspectable.length} inspectable files; product code was read first and supporting files were cut off.`);
|
|
239
|
+
if (budgetExhausted)
|
|
240
|
+
warnings.push(`Inventory text budget reached after ${filesScanned} of ${inspectable.length} inspectable files; product code was read first.`);
|
|
241
|
+
return {
|
|
242
|
+
name: source.name,
|
|
243
|
+
kind: 'directory',
|
|
244
|
+
location: source.path,
|
|
245
|
+
revision,
|
|
246
|
+
filesScanned,
|
|
247
|
+
filesAvailable: files.length,
|
|
248
|
+
truncated,
|
|
249
|
+
languages: [...languages].filter(Boolean).sort(),
|
|
250
|
+
packageNames: [...packageNames].sort(),
|
|
251
|
+
evidence: uniqueEvidence(evidence),
|
|
252
|
+
uiLabelCatalogs: uiLabelCatalogs.sort(),
|
|
253
|
+
warnings,
|
|
254
|
+
...(source.scope ? { scope: source.scope } : {}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
async function discoverOpenApiSource(root, source, revision) {
|
|
258
|
+
const loaded = await loadOpenApiSource(root, source);
|
|
259
|
+
const evidence = openApiEvidence(source.name, portable(source.path), loaded);
|
|
260
|
+
return {
|
|
261
|
+
name: source.name,
|
|
262
|
+
kind: 'openapi',
|
|
263
|
+
location: source.path,
|
|
264
|
+
revision,
|
|
265
|
+
filesScanned: 1,
|
|
266
|
+
filesAvailable: 1,
|
|
267
|
+
truncated: false,
|
|
268
|
+
languages: ['OpenAPI'],
|
|
269
|
+
packageNames: [],
|
|
270
|
+
evidence: uniqueEvidence(evidence),
|
|
271
|
+
uiLabelCatalogs: [],
|
|
272
|
+
warnings: [],
|
|
273
|
+
...(source.scope ? { scope: source.scope } : {}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Signals for one API contract: the product, every operation, schema,
|
|
278
|
+
* security scheme, documented 4xx/5xx response, and webhook. Shared by
|
|
279
|
+
* OpenAPI sources and by specs found inside a repository.
|
|
280
|
+
*/
|
|
281
|
+
export function openApiEvidence(source, path, loaded) {
|
|
282
|
+
const contract = (kind, label) => ({ source, path, kind, label, contract: true });
|
|
283
|
+
const paths = record(loaded.document.paths);
|
|
284
|
+
const errors = [];
|
|
285
|
+
for (const operation of Object.keys(loaded.snapshot.operations)) {
|
|
286
|
+
const [method = '', ...rest] = operation.split(' ');
|
|
287
|
+
const responses = record(record(record(paths[rest.join(' ')])[method.toLowerCase()]).responses);
|
|
288
|
+
for (const status of Object.keys(responses))
|
|
289
|
+
if (/^[45](?:\d\d|XX)$/i.test(status))
|
|
290
|
+
errors.push(contract('error', `${operation} ${status}`));
|
|
291
|
+
}
|
|
292
|
+
return [
|
|
293
|
+
{ source, path, kind: 'package', label: loaded.summary.title },
|
|
294
|
+
...Object.keys(loaded.snapshot.operations).map((label) => contract('operation', label)),
|
|
295
|
+
...Object.keys(loaded.snapshot.schemas).map((schema) => contract('export', `Schema ${schema}`)),
|
|
296
|
+
...loaded.summary.securitySchemes.map((scheme) => contract('authentication', scheme)),
|
|
297
|
+
...errors,
|
|
298
|
+
...Object.keys(loaded.snapshot.operations).filter((operation) => /webhook|event|callback/i.test(operation)).map((operation) => contract('integration', operation)),
|
|
299
|
+
...Object.keys(record(loaded.document.webhooks)).flatMap((webhook) => [contract('event', webhook), contract('integration', `Webhook ${webhook}`)]),
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* OpenAPI and Swagger files kept inside a repository. RealWorld keeps its
|
|
304
|
+
* contract at `specs/api/openapi.yml`; classified by folder it is a test
|
|
305
|
+
* file and yielded nothing, so an API-only product added as a repository
|
|
306
|
+
* showed no operations at all. Test fixtures and vendored specs are not the
|
|
307
|
+
* product's contract.
|
|
308
|
+
*/
|
|
309
|
+
const OPENAPI_FILE_NAME = /(?:^|\/)(?:[^/]*(?:openapi|swagger)[^/]*|api)\.(?:ya?ml|json)$/i;
|
|
310
|
+
const OPENAPI_NOISE_PATH = /(^|\/)(?:__tests__|tests?|testdata|fixtures?|mocks?|__mocks__|node_modules|vendor|third[_-]?party|examples?|samples?)(\/|$)/i;
|
|
311
|
+
const OPENAPI_HEADER = /^\s*["']?(?:openapi|swagger)["']?\s*:\s*["']?\d/m;
|
|
312
|
+
const MAX_OPENAPI_FILES_PER_SOURCE = 5;
|
|
313
|
+
async function repositoryOpenApiEvidence(source, files, warnings) {
|
|
314
|
+
const evidence = [];
|
|
315
|
+
let found = 0;
|
|
316
|
+
for (const { path, sourcePath } of files) {
|
|
317
|
+
if (found >= MAX_OPENAPI_FILES_PER_SOURCE)
|
|
318
|
+
break;
|
|
319
|
+
if (!OPENAPI_FILE_NAME.test(sourcePath) || OPENAPI_NOISE_PATH.test(sourcePath))
|
|
320
|
+
continue;
|
|
321
|
+
let content;
|
|
322
|
+
try {
|
|
323
|
+
if ((await lstat(path)).size > 5 * 1024 * 1024)
|
|
324
|
+
continue;
|
|
325
|
+
content = await readFile(path, 'utf8');
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!OPENAPI_HEADER.test(content.slice(0, 4096)))
|
|
331
|
+
continue;
|
|
332
|
+
try {
|
|
333
|
+
evidence.push(...openApiEvidence(source, sourcePath, parseOpenApi(content, sourcePath)));
|
|
334
|
+
found += 1;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
warnings.push(`API contract ${sourcePath} could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return evidence;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* One `documentation` row per crawled page, labeled with the page title, so
|
|
344
|
+
* the planner sees the whole existing site map without opening every file.
|
|
345
|
+
* The snapshot's `index.md` carries the same table with original URLs.
|
|
346
|
+
*/
|
|
347
|
+
async function discoverDocsSiteSource(root, source, revision) {
|
|
348
|
+
const manifest = await readDocsSiteManifest(root, source);
|
|
349
|
+
const evidence = [
|
|
350
|
+
{ source: source.name, path: 'index.md', kind: 'documentation', label: `Existing documentation site ${manifest.url} (${manifest.totals.pages} pages)` },
|
|
351
|
+
...manifest.pages.slice(0, MAX_SIGNALS_PER_SOURCE - 1).map((page) => ({ source: source.name, path: page.file, kind: 'documentation', label: page.title })),
|
|
352
|
+
];
|
|
353
|
+
const warnings = [...manifest.warnings];
|
|
354
|
+
if (manifest.brokenLinks.length > 0)
|
|
355
|
+
warnings.push(`${manifest.brokenLinks.length} internal links on the existing site point at pages that failed to load; see index.md.`);
|
|
356
|
+
return {
|
|
357
|
+
name: source.name,
|
|
358
|
+
kind: 'docs-site',
|
|
359
|
+
location: manifest.url,
|
|
360
|
+
revision: revision ?? manifest.hash,
|
|
361
|
+
filesScanned: manifest.pages.length,
|
|
362
|
+
filesAvailable: manifest.pages.length,
|
|
363
|
+
truncated: Boolean(manifest.truncated),
|
|
364
|
+
languages: ['Markdown'],
|
|
365
|
+
packageNames: [],
|
|
366
|
+
evidence,
|
|
367
|
+
uiLabelCatalogs: [],
|
|
368
|
+
warnings,
|
|
369
|
+
...(source.scope ? { scope: source.scope } : {}),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
async function safeSourceFiles(root, ignoredGlobs) {
|
|
373
|
+
const files = [];
|
|
374
|
+
await walk(root, root, ignoredGlobs, files);
|
|
375
|
+
return files.sort();
|
|
376
|
+
}
|
|
377
|
+
async function safeDirectoryFingerprint(root, ignoredGlobs) {
|
|
378
|
+
const files = await safeSourceFiles(root, ignoredGlobs);
|
|
379
|
+
const hash = createHash('sha256');
|
|
380
|
+
for (const path of files) {
|
|
381
|
+
const sourcePath = portable(relative(root, path));
|
|
382
|
+
hash.update(sourcePath);
|
|
383
|
+
hash.update('\0');
|
|
384
|
+
try {
|
|
385
|
+
hash.update(await readFile(path));
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
hash.update('unreadable');
|
|
389
|
+
}
|
|
390
|
+
hash.update('\0');
|
|
391
|
+
}
|
|
392
|
+
return files.length ? hash.digest('hex') : null;
|
|
393
|
+
}
|
|
394
|
+
async function walk(root, directory, ignoredGlobs, files) {
|
|
395
|
+
let entries;
|
|
396
|
+
try {
|
|
397
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
403
|
+
const path = join(directory, entry.name);
|
|
404
|
+
const relativePath = portable(relative(root, path));
|
|
405
|
+
if (entry.isSymbolicLink() || isSensitiveSourcePath(relativePath) || matchesAnyGlob(relativePath, ignoredGlobs))
|
|
406
|
+
continue;
|
|
407
|
+
if (entry.isDirectory()) {
|
|
408
|
+
if (!IGNORED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.'))
|
|
409
|
+
await walk(root, path, ignoredGlobs, files);
|
|
410
|
+
}
|
|
411
|
+
else if (entry.isFile())
|
|
412
|
+
files.push(path);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const FIXTURE_PATH = /(^|\/)(?:evals?|evaluations?|fixtures?|__fixtures__|__mocks__|mocks?|testdata|test-data|snapshots?|__snapshots__|e2e|benchmarks?|playground|sandbox|scripts?|tools?|\.?storybook|stories)(\/|$)/i;
|
|
416
|
+
/**
|
|
417
|
+
* Deployment, database, and maintenance code. Its environment variables are
|
|
418
|
+
* real operator configuration, but a migration that creates an `api_key`
|
|
419
|
+
* table or a helper that exits with status 1 is not a reader-facing
|
|
420
|
+
* authentication surface or command.
|
|
421
|
+
*/
|
|
422
|
+
const INFRASTRUCTURE_PATH = /(^|\/)(?:db|database|migrations?|migrate|seeds?|prisma|drizzle|docker|podman|k8s|kubernetes|helm|charts?|deploy|deployment|infra|infrastructure|terraform|ansible|extra|extras|ci|\.github|\.gitlab|\.circleci)(\/|$)/i;
|
|
423
|
+
// Test files by each ecosystem's own convention: `*.test.ts`, Go's
|
|
424
|
+
// `*_test.go`, Python's `test_*.py` / `*_test.py`, Ruby's `*_spec.rb`,
|
|
425
|
+
// Java/C#'s `*Test.java` / `*Tests.cs`.
|
|
426
|
+
const TEST_PATH = /(^|\/)(?:__tests__|tests?|specs?)(\/|$)|\.(?:test|spec|stories)\.[^.]+$|_test\.(?:go|py|rb|exs?)$|(^|\/)test_[^/]+\.py$|_spec\.rb$|Tests?\.(?:java|kt|cs)$/;
|
|
427
|
+
const EXAMPLE_PATH = /(^|\/)(?:examples?|demos?|samples?|recipes?)(\/|$)/i;
|
|
428
|
+
const DOCUMENTATION_PATH = /(^|\/)(?:docs?|documentation|skills?|prompts?|references?|guides?|wiki|adr|rfcs?|proposals?)(\/|$)|\.(?:md|mdx|rst|txt)$/i;
|
|
429
|
+
const ASSET_PATH = /(^|\/)(?:assets?|public|static|images?|fonts?|media)(\/|$)/i;
|
|
430
|
+
const CODE_EXTENSIONS = new Set(['.c', '.cc', '.cpp', '.cs', '.go', '.graphql', '.gql', '.h', '.hpp', '.java', '.js', '.jsx', '.kt', '.php', '.proto', '.py', '.rb', '.rs', '.swift', '.ts', '.tsx', '.vue']);
|
|
431
|
+
export function classifySourceFile(path) {
|
|
432
|
+
if (FIXTURE_PATH.test(path))
|
|
433
|
+
return 'fixture';
|
|
434
|
+
if (TEST_PATH.test(path))
|
|
435
|
+
return 'test';
|
|
436
|
+
if (EXAMPLE_PATH.test(path))
|
|
437
|
+
return 'example';
|
|
438
|
+
if (DOCUMENTATION_PATH.test(path))
|
|
439
|
+
return 'documentation';
|
|
440
|
+
if (ASSET_PATH.test(path))
|
|
441
|
+
return 'asset';
|
|
442
|
+
if (INFRASTRUCTURE_PATH.test(path))
|
|
443
|
+
return 'infrastructure';
|
|
444
|
+
return CODE_EXTENSIONS.has(extname(path).toLowerCase()) ? 'code' : 'other';
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Front-end source of a web app or admin dashboard. Its components mention
|
|
448
|
+
* sign-in, providers, and events on every screen without being an API or
|
|
449
|
+
* integration surface of their own; what the reader sees there is covered by
|
|
450
|
+
* UI journeys and screenshots, so keyword families do not apply to it.
|
|
451
|
+
*/
|
|
452
|
+
const FRONTEND_PATH = /(^|\/)(?:ui|web|webapp|frontend|client|dashboard|admin|app-ui|desktop|components|pages|views|layouts|composables|stores|[\w-]*-(?:web|desktop|ui|frontend))\/.*\.(?:jsx?|tsx?|mjs|vue|svelte)$|\.(?:vue|svelte|jsx|tsx)$/i;
|
|
453
|
+
/** Keyword-derived signal kinds, counted once per folder rather than per file. */
|
|
454
|
+
export const KEYWORD_SIGNAL_KINDS = new Set(['authentication', 'authorization', 'integration', 'event']);
|
|
455
|
+
/** Build and shell variables every toolchain reads; not product configuration. */
|
|
456
|
+
export const TOOLCHAIN_ENV = /^(?:NODE_ENV|CI|HOME|PATH|PWD|SHELL|TERM|USER|LANG|TZ|TMPDIR|npm_\w+|TAURI_\w+|VITEST\w*|JEST_\w+|GITHUB_\w+|RUNNER_\w+)$/;
|
|
457
|
+
const MONOREPO_ROOTS = new Set(['packages', 'apps', 'libs', 'services', 'crates', 'modules', 'plugins', 'extensions']);
|
|
458
|
+
/**
|
|
459
|
+
* The module a file belongs to, for counting keyword signals once per module:
|
|
460
|
+
* a folder at most three levels deep, or four inside a monorepo's
|
|
461
|
+
* `packages/<name>/src/<module>`. A page citing any file in the module covers
|
|
462
|
+
* it. Shared by discovery and coverage so both group the same way.
|
|
463
|
+
*/
|
|
464
|
+
export function signalModule(path) {
|
|
465
|
+
const segments = path.split('/').slice(0, -1);
|
|
466
|
+
if (segments.length === 0)
|
|
467
|
+
return '.';
|
|
468
|
+
const depth = MONOREPO_ROOTS.has(segments[0]) ? 4 : 3;
|
|
469
|
+
return segments.slice(0, depth).join('/');
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Framework-owned HTTP routes that no `app.get(...)` line declares. A Next.js
|
|
473
|
+
* App Router handler lives at `app/api/users/[id]/route.ts` and exports one
|
|
474
|
+
* function per method, so the route is derived from the file path.
|
|
475
|
+
*/
|
|
476
|
+
export function frameworkRoutes(path, content) {
|
|
477
|
+
const match = /(?:^|\/)app\/(.*?)\/?route\.(?:ts|tsx|js|jsx|mjs)$/.exec(path);
|
|
478
|
+
if (!match)
|
|
479
|
+
return [];
|
|
480
|
+
const route = `/${match[1]
|
|
481
|
+
.split('/')
|
|
482
|
+
.filter((segment) => segment && !/^\(.*\)$/.test(segment) && !/^@/.test(segment))
|
|
483
|
+
.map((segment) => segment.replace(/^\[\[?\.\.\.([^\]]+)\]?\]$/, ':$1*').replace(/^\[([^\]]+)\]$/, ':$1'))
|
|
484
|
+
.join('/')}`;
|
|
485
|
+
const methods = [...content.matchAll(/\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g)].map((item) => item[1]);
|
|
486
|
+
const aliased = [...content.matchAll(/\bexport\s*\{[^}]*\b(?:as\s+)?(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b[^}]*\}/g)].map((item) => item[1]);
|
|
487
|
+
return [...new Set([...methods, ...aliased])].map((method) => `${method} ${route}`);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Lines that mention authentication or providers without being behavior:
|
|
491
|
+
* comments, markup, translated UI strings, and SQL. A Vue template that
|
|
492
|
+
* renders `$t("Add API Key")` is a label, not an authentication surface.
|
|
493
|
+
*/
|
|
494
|
+
function isProseLine(line, inTemplate) {
|
|
495
|
+
const trimmed = line.trim();
|
|
496
|
+
return inTemplate
|
|
497
|
+
|| /^(?:\/\/|#|\*|\/\*|<!--|--|\{\/\*)/.test(trimmed)
|
|
498
|
+
|| /^<[A-Za-z!/]/.test(trimmed)
|
|
499
|
+
|| /\$t\(|\bt\(\s*['"`]|i18n|\btranslate\(/.test(trimmed)
|
|
500
|
+
|| /^\s*(?:CREATE|ALTER|DROP|INSERT|UPDATE|SELECT)\s/i.test(trimmed);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Exports are public surface only in a package people install. A monorepo's
|
|
504
|
+
* apps and internal libraries are `"private": true`, and their hundreds of
|
|
505
|
+
* index exports read as undocumented API to a coverage count.
|
|
506
|
+
*/
|
|
507
|
+
function exportsArePublic(sourcePath, packages) {
|
|
508
|
+
let nearest;
|
|
509
|
+
for (const entry of packages) {
|
|
510
|
+
if (entry.dir === '' || sourcePath.startsWith(`${entry.dir}/`)) {
|
|
511
|
+
if (!nearest || entry.dir.length > nearest.dir.length)
|
|
512
|
+
nearest = entry;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return !nearest?.private;
|
|
516
|
+
}
|
|
517
|
+
async function packageEntryPoints(sourceRoot, files, packages = []) {
|
|
518
|
+
const stems = new Set(['index', 'main', 'lib', 'mod', 'public-api', '__init__']);
|
|
519
|
+
for (const path of files) {
|
|
520
|
+
if (path.split(/[\\/]/).at(-1) !== 'package.json' || classifySourceFile(portable(relative(sourceRoot, path))) === 'fixture')
|
|
521
|
+
continue;
|
|
522
|
+
let json;
|
|
523
|
+
try {
|
|
524
|
+
json = safeJson(await readFile(path, 'utf8'));
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (!json)
|
|
530
|
+
continue;
|
|
531
|
+
const manifest = portable(relative(sourceRoot, path));
|
|
532
|
+
packages.push({ dir: manifest.includes('/') ? manifest.slice(0, manifest.lastIndexOf('/')) : '', private: json.private === true });
|
|
533
|
+
const values = [json.main, json.module, json.types, json.typings, json.browser];
|
|
534
|
+
values.push(...Object.values(record(json.bin)));
|
|
535
|
+
const walkExports = (value) => {
|
|
536
|
+
if (typeof value === 'string')
|
|
537
|
+
values.push(value);
|
|
538
|
+
else if (value && typeof value === 'object')
|
|
539
|
+
Object.values(value).forEach(walkExports);
|
|
540
|
+
};
|
|
541
|
+
walkExports(json.exports);
|
|
542
|
+
for (const value of values) {
|
|
543
|
+
if (typeof value !== 'string')
|
|
544
|
+
continue;
|
|
545
|
+
const stem = value.replace(/\\/g, '/').split('/').at(-1)?.replace(/\.[^.]+$/, '').replace(/\.d$/, '');
|
|
546
|
+
if (stem && stem !== '.' && stem !== '*')
|
|
547
|
+
stems.add(stem);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return stems;
|
|
551
|
+
}
|
|
552
|
+
function isPublicEntryPoint(path, entryPoints) {
|
|
553
|
+
const stem = path.split('/').at(-1)?.replace(/\.[^.]+$/, '').replace(/\.d$/, '') ?? '';
|
|
554
|
+
return entryPoints.has(stem);
|
|
555
|
+
}
|
|
556
|
+
function inspectTextFile(source, path, content, evidence, packages, entryPoints = new Set(['index', 'main', 'lib', 'mod'])) {
|
|
557
|
+
const base = path.split('/').at(-1)?.toLowerCase() ?? '';
|
|
558
|
+
const role = classifySourceFile(path);
|
|
559
|
+
if (base === 'package.json' && role !== 'fixture') {
|
|
560
|
+
const json = safeJson(content);
|
|
561
|
+
const name = text(json?.name);
|
|
562
|
+
if (name)
|
|
563
|
+
packages.add(name);
|
|
564
|
+
if (name)
|
|
565
|
+
evidence.push({ source, path, kind: 'package', label: name });
|
|
566
|
+
for (const command of Object.keys(record(json?.bin)))
|
|
567
|
+
evidence.push({ source, path, kind: 'command', label: command });
|
|
568
|
+
for (const exported of Object.keys(record(json?.exports)))
|
|
569
|
+
evidence.push({ source, path, kind: 'export', label: exported });
|
|
570
|
+
// Operator-facing scripts only. A repository's forty `test-*`, `lint`,
|
|
571
|
+
// and `build-docker-nightly` scripts are contributor tooling, and each
|
|
572
|
+
// one counted as an undocumented public command.
|
|
573
|
+
// Only the repository's own package: `ui/package.json` scripts build the
|
|
574
|
+
// bundled dashboard and are contributor tooling, not reader commands.
|
|
575
|
+
for (const script of (path === 'package.json' ? Object.keys(record(json?.scripts)) : []).filter((item) => /^(?:start|dev|serve|build|migrate|deploy|preview|setup)(?::[a-z0-9-]+)?$/.test(item))) {
|
|
576
|
+
evidence.push({ source, path, kind: 'command', label: `npm run ${script}` });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const fileKind = role === 'example' ? 'example'
|
|
580
|
+
: role === 'test' || role === 'fixture' ? 'test'
|
|
581
|
+
: role === 'documentation' ? 'documentation'
|
|
582
|
+
: undefined;
|
|
583
|
+
if (fileKind)
|
|
584
|
+
evidence.push({ source, path, kind: fileKind, label: path });
|
|
585
|
+
// Key extraction applies to data configuration files (YAML, TOML, JSON,
|
|
586
|
+
// .env.example). A `vitest.config.ts` is build tooling, and its keys are
|
|
587
|
+
// not product configuration.
|
|
588
|
+
const configurationFile = role !== 'code' && /(?:^|\/)(?:config|configuration|settings)(?:[./_-]|$)|\.env\.example$/i.test(path);
|
|
589
|
+
// Prose, fixtures, tests, and examples are evidence about behavior, never
|
|
590
|
+
// behavior themselves. Only product code and configuration files below.
|
|
591
|
+
if (role !== 'code' && role !== 'infrastructure' && !(role === 'other' && configurationFile))
|
|
592
|
+
return;
|
|
593
|
+
// Deployment and database code contributes operator configuration only.
|
|
594
|
+
if (role === 'infrastructure') {
|
|
595
|
+
content.split(/\r?\n/).forEach((line, index) => {
|
|
596
|
+
for (const match of line.matchAll(/\b(?:process\.env\.|env\[['"`]|ENV\[['"`])([A-Z][A-Z0-9_]{2,})/g)) {
|
|
597
|
+
if (!TOOLCHAIN_ENV.test(match[1]))
|
|
598
|
+
evidence.push({ source, path, kind: 'configuration', label: match[1], line: index + 1 });
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
for (const route of frameworkRoutes(path, content))
|
|
604
|
+
evidence.push({ source, path, kind: 'route', label: route });
|
|
605
|
+
const publicExports = isPublicEntryPoint(path, entryPoints);
|
|
606
|
+
// One signal per keyword family per file: "this module authenticates" is a
|
|
607
|
+
// public-surface fact; the forty lines that mention a token are not.
|
|
608
|
+
const seenKeywords = new Set();
|
|
609
|
+
const frontend = FRONTEND_PATH.test(path);
|
|
610
|
+
const lines = content.split(/\r?\n/);
|
|
611
|
+
let inTemplate = false;
|
|
612
|
+
lines.forEach((line, index) => {
|
|
613
|
+
if (path.endsWith('.vue')) {
|
|
614
|
+
if (/^\s*<template\b/.test(line))
|
|
615
|
+
inTemplate = true;
|
|
616
|
+
else if (/^\s*<\/template>/.test(line))
|
|
617
|
+
inTemplate = false;
|
|
618
|
+
}
|
|
619
|
+
const prose = isProseLine(line, inTemplate);
|
|
620
|
+
const exported = /\bexport\s+(?:default\s+)?(?:async\s+)?(?:class|function|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/.exec(line)?.[1]
|
|
621
|
+
?? /\bpub\s+(?:async\s+)?(?:fn|struct|enum|trait|type)\s+([A-Za-z_][\w]*)/.exec(line)?.[1];
|
|
622
|
+
if (exported && publicExports)
|
|
623
|
+
evidence.push({ source, path, kind: 'export', label: exported, line: index + 1 });
|
|
624
|
+
const route = /\b(?:app|router|server)\.(get|post|put|patch|delete|options|head)\s*\(\s*['"`]([^'"`]+)/i.exec(line)
|
|
625
|
+
?? /\bmap(get|post|put|patch|delete|options|head)\s*\(\s*['"`]([^'"`]+)/i.exec(line)
|
|
626
|
+
?? /@(get|post|put|patch|delete|request)mapping\s*\(\s*(?:value\s*=\s*)?['"`]([^'"`]+)/i.exec(line)
|
|
627
|
+
// Go routers (net/http 1.22 patterns aside): `rg.GET("/records", h)`,
|
|
628
|
+
// gin/echo/chi/PocketBase style, with an upper-case method name.
|
|
629
|
+
?? /\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*["'`](\/[^"'`]*)/.exec(line);
|
|
630
|
+
if (route)
|
|
631
|
+
evidence.push({ source, path, kind: 'route', label: `${route[1].toUpperCase()} ${route[2]}`, line: index + 1 });
|
|
632
|
+
const command = /\.(?:command|option)\(\s*['"`]([^'"`]+)/.exec(line)?.[1];
|
|
633
|
+
if (command)
|
|
634
|
+
evidence.push({ source, path, kind: 'command', label: command, line: index + 1 });
|
|
635
|
+
const configuration = /\b(?:interface|type|class)\s+([A-Za-z_$][\w$]*(?:Config|Options|Settings))\b/.exec(line)?.[1];
|
|
636
|
+
if (configuration && publicExports)
|
|
637
|
+
evidence.push({ source, path, kind: 'configuration', label: configuration, line: index + 1 });
|
|
638
|
+
for (const match of line.matchAll(/\b(?:process\.env\.|env\[['"`]|ENV\[['"`])([A-Z][A-Z0-9_]{2,})/g)) {
|
|
639
|
+
if (!TOOLCHAIN_ENV.test(match[1]))
|
|
640
|
+
evidence.push({ source, path, kind: 'configuration', label: match[1], line: index + 1 });
|
|
641
|
+
}
|
|
642
|
+
const option = /(?:add_argument|addOption|option)\(\s*['"`](-{1,2}[a-z0-9][\w-]*)/i.exec(line)?.[1]
|
|
643
|
+
?? /\b(?:flag\.(?:String|Bool|Int|Duration)|StringVar|BoolVar|IntVar)\s*\([^,]*,?\s*['"`]([a-z0-9][\w-]*)/i.exec(line)?.[1];
|
|
644
|
+
if (option)
|
|
645
|
+
evidence.push({ source, path, kind: 'command', label: option, line: index + 1 });
|
|
646
|
+
// Comments, markup, and translated strings mention these words without
|
|
647
|
+
// implementing anything; keyword families below apply to code lines only.
|
|
648
|
+
if (prose || frontend)
|
|
649
|
+
return;
|
|
650
|
+
const authentication = /\b(oauth2?|oidc|sso|bearer|api[_ -]?key|access[_ -]?token|refresh[_ -]?token|authenticate|authentication|sign[ -]?in|login)(?:\b|(?=[A-Z_]))/i.exec(line)?.[1];
|
|
651
|
+
if (authentication && keywordOnce(seenKeywords, 'authentication', authentication))
|
|
652
|
+
evidence.push({ source, path, kind: 'authentication', label: conciseLabel(line, authentication), line: index + 1 });
|
|
653
|
+
// Bare "scope", "policy", and "role" are ordinary vocabulary in most code;
|
|
654
|
+
// count them only beside an access-control word.
|
|
655
|
+
const authorization = /\b(authori[sz]e|authorization|permission|permissions|rbac)\b/i.exec(line)?.[1]
|
|
656
|
+
?? (/\b(?:auth|token|grant|access|permission|admin|user)/i.test(line) ? /\b(role|roles|scope|scopes|policy)\b/i.exec(line)?.[1] : undefined);
|
|
657
|
+
if (authorization && keywordOnce(seenKeywords, 'authorization', authorization))
|
|
658
|
+
evidence.push({ source, path, kind: 'authorization', label: conciseLabel(line, authorization), line: index + 1 });
|
|
659
|
+
const error = /\b(?:throw\s+new\s+([A-Za-z_$][\w$]*Error)|raise\s+([A-Za-z_$][\w$]*(?:Error|Exception))|status\s*[:=]\s*(4\d\d|5\d\d)|HTTPException\s*\(\s*status_code\s*=\s*(\d{3}))/i.exec(line);
|
|
660
|
+
const errorLabel = error ? error.slice(1).find(Boolean) ?? 'error contract' : undefined;
|
|
661
|
+
if (errorLabel && keywordOnce(seenKeywords, 'error', errorLabel))
|
|
662
|
+
evidence.push({ source, path, kind: 'error', label: errorLabel, line: index + 1 });
|
|
663
|
+
const event = /\b(?:emit|publish|dispatch|subscribe)\(\s*['"`]([^'"`]+)['"`]/i.exec(line)?.[1]
|
|
664
|
+
?? /\b(event|webhook)\s*[:=]\s*['"`]([^'"`]+)['"`]/i.exec(line)?.[2];
|
|
665
|
+
if (event)
|
|
666
|
+
evidence.push({ source, path, kind: 'event', label: event, line: index + 1 });
|
|
667
|
+
const integration = /(webhook|integration|connector|plugin|provider|adapter)/i.exec(line)?.[1];
|
|
668
|
+
if (integration && keywordOnce(seenKeywords, 'integration', integration))
|
|
669
|
+
evidence.push({ source, path, kind: 'integration', label: conciseLabel(line, integration), line: index + 1 });
|
|
670
|
+
if (configurationFile) {
|
|
671
|
+
const key = /^\s{0,4}([A-Za-z][A-Za-z0-9_.-]{2,})\s*[:=]/.exec(line)?.[1];
|
|
672
|
+
if (key && !['const', 'export', 'function', 'import', 'return'].includes(key.toLowerCase()))
|
|
673
|
+
evidence.push({ source, path, kind: 'configuration', label: key, line: index + 1 });
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
function keywordOnce(seen, family, keyword) {
|
|
678
|
+
const key = `${family}:${keyword.toLowerCase()}`;
|
|
679
|
+
if (seen.has(key))
|
|
680
|
+
return false;
|
|
681
|
+
seen.add(key);
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
function conciseLabel(line, fallback) {
|
|
685
|
+
const normalized = line.trim().replace(/\s+/g, ' ');
|
|
686
|
+
return normalized.length > 100 ? fallback.toLowerCase() : normalized;
|
|
687
|
+
}
|
|
688
|
+
async function safeExistingPages(root, project) {
|
|
689
|
+
try {
|
|
690
|
+
return await loadPages(root, project);
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
return [];
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
async function existingNavigationFiles(root, project) {
|
|
697
|
+
const candidates = ['docs.json', 'sidebars.js', 'sidebars.ts', 'mkdocs.yml', 'mkdocs.yaml', 'astro.config.mjs', 'docusaurus.config.js', 'docusaurus.config.ts', 'nav.yml', 'nav.yaml'];
|
|
698
|
+
const roots = [root, resolve(root, project.contentDir)];
|
|
699
|
+
const found = [];
|
|
700
|
+
for (const directory of roots) {
|
|
701
|
+
for (const candidate of candidates) {
|
|
702
|
+
const path = join(directory, candidate);
|
|
703
|
+
try {
|
|
704
|
+
if ((await lstat(path)).isFile())
|
|
705
|
+
found.push(portable(relative(root, path)));
|
|
706
|
+
}
|
|
707
|
+
catch { /* absent candidates are expected */ }
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return [...new Set(found)].sort();
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Evidence-derived page estimates. Comprehensive scales with the public
|
|
714
|
+
* surface instead of stopping at a fixed ceiling: a product with sixty
|
|
715
|
+
* commands, routes, and configuration groups needs more than thirty pages, and
|
|
716
|
+
* the reviewer can raise or lower the target page count on the plan anyway.
|
|
717
|
+
*/
|
|
718
|
+
export const MAXIMUM_SUGGESTED_PAGES = 120;
|
|
719
|
+
export function suggestedPageCounts(signals, existingPages) {
|
|
720
|
+
const complexity = Math.max(1, Math.ceil(signals / 6), Math.ceil(existingPages / 2));
|
|
721
|
+
return {
|
|
722
|
+
starter: Math.min(5, Math.max(3, 2 + Math.ceil(complexity / 4))),
|
|
723
|
+
standard: Math.min(MAXIMUM_SUGGESTED_PAGES, Math.max(7, 6 + Math.ceil(complexity / 2))),
|
|
724
|
+
comprehensive: Math.min(MAXIMUM_SUGGESTED_PAGES, Math.max(12, 11 + complexity)),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Keyword signals ("this file mentions OAuth") count once per folder and
|
|
729
|
+
* family: a module that authenticates is one surface to document, not one
|
|
730
|
+
* per file. The signal's path is the folder, and its label names the
|
|
731
|
+
* keywords found there, so the gap reads as "Authentication in apis".
|
|
732
|
+
*/
|
|
733
|
+
export function groupKeywordSignals(items) {
|
|
734
|
+
const groups = new Map();
|
|
735
|
+
const out = [];
|
|
736
|
+
for (const item of items) {
|
|
737
|
+
// A contract's security schemes and webhooks are exact names, not keyword hits.
|
|
738
|
+
if (!KEYWORD_SIGNAL_KINDS.has(item.kind) || item.contract) {
|
|
739
|
+
out.push(item);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const folder = signalModule(item.path);
|
|
743
|
+
const key = `${item.source}\0${item.kind}\0${folder}`;
|
|
744
|
+
const keyword = item.kind === 'event' ? item.label.slice(0, 40) : keywordOf(item.label);
|
|
745
|
+
const group = groups.get(key);
|
|
746
|
+
if (group) {
|
|
747
|
+
if (keyword)
|
|
748
|
+
group.keywords.add(keyword);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const created = { item: { source: item.source, kind: item.kind, path: folder, label: '' }, keywords: new Set(keyword ? [keyword] : []) };
|
|
752
|
+
groups.set(key, created);
|
|
753
|
+
out.push(created.item);
|
|
754
|
+
}
|
|
755
|
+
for (const { item, keywords } of groups.values()) {
|
|
756
|
+
const family = item.kind === 'authentication' ? 'Authentication' : item.kind === 'authorization' ? 'Permissions' : item.kind === 'event' ? 'Events' : 'Integrations';
|
|
757
|
+
const found = [...keywords].slice(0, 4).join(', ');
|
|
758
|
+
item.label = `${family} in ${item.path === '.' ? 'the project root' : item.path}${found ? ` (${found})` : ''}`;
|
|
759
|
+
}
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
function keywordOf(label) {
|
|
763
|
+
return /\b(oauth2?|oidc|sso|bearer|api[_ -]?key|access[_ -]?token|refresh[_ -]?token|authenticate|authentication|sign[ -]?in|login|authori[sz]e|authorization|permissions?|rbac|roles?|scopes?|policy|webhook|integration|connector|plugin|provider|adapter)/i.exec(label)?.[1]?.toLowerCase();
|
|
764
|
+
}
|
|
765
|
+
function uniqueEvidence(items) {
|
|
766
|
+
const seen = new Set();
|
|
767
|
+
return groupKeywordSignals(items).filter((item) => {
|
|
768
|
+
const key = `${item.source}\0${item.path}\0${item.kind}\0${item.label}`;
|
|
769
|
+
if (seen.has(key))
|
|
770
|
+
return false;
|
|
771
|
+
seen.add(key);
|
|
772
|
+
return true;
|
|
773
|
+
}).sort((left, right) => evidencePriority(left.kind) - evidencePriority(right.kind)).slice(0, MAX_SIGNALS_PER_SOURCE);
|
|
774
|
+
}
|
|
775
|
+
function isSensitiveSourcePath(path) {
|
|
776
|
+
const segments = path.toLowerCase().split(/[\\/]/);
|
|
777
|
+
const name = segments.at(-1) ?? '';
|
|
778
|
+
return segments.includes('.ssh') || name === '.env' || (name.startsWith('.env.') && name !== '.env.example') ||
|
|
779
|
+
name === 'credentials' || name === 'credentials.json' || name === 'id_rsa' || name === 'id_ed25519' ||
|
|
780
|
+
name.endsWith('.key') || name.endsWith('.pem') || name.endsWith('.p12') || name.endsWith('.pfx');
|
|
781
|
+
}
|
|
782
|
+
function languageForExtension(extension) {
|
|
783
|
+
return { '.js': 'JavaScript', '.jsx': 'JavaScript', '.ts': 'TypeScript', '.tsx': 'TypeScript', '.py': 'Python', '.go': 'Go', '.rs': 'Rust', '.java': 'Java', '.kt': 'Kotlin', '.rb': 'Ruby', '.php': 'PHP', '.cs': 'C#', '.swift': 'Swift', '.proto': 'Protocol Buffers', '.graphql': 'GraphQL', '.gql': 'GraphQL', '.yaml': 'YAML', '.yml': 'YAML', '.json': 'JSON', '.md': 'Markdown', '.mdx': 'MDX', '.rst': 'reStructuredText' }[extension] ?? extension.replace(/^\./, '').toUpperCase();
|
|
784
|
+
}
|
|
785
|
+
function safeJson(content) {
|
|
786
|
+
try {
|
|
787
|
+
return record(JSON.parse(content));
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function record(value) {
|
|
794
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
795
|
+
}
|
|
796
|
+
function text(value) {
|
|
797
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
798
|
+
}
|
|
799
|
+
function portable(path) { return path.split('\\').join('/'); }
|
|
800
|
+
function isDiscoveryInventory(value, cacheKey) {
|
|
801
|
+
return value?.schemaVersion === DISCOVERY_SCHEMA_VERSION && value.cacheKey === cacheKey && Array.isArray(value.sources);
|
|
802
|
+
}
|
|
803
|
+
function evidencePriority(kind) {
|
|
804
|
+
const index = KIND_PRIORITY.indexOf(kind);
|
|
805
|
+
return index === -1 ? KIND_PRIORITY.length : index;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* What the planner must know about the inventory beyond its rows: which
|
|
809
|
+
* files hold the strings readers see, and whether the inventory is partial.
|
|
810
|
+
*/
|
|
811
|
+
export function discoveryGuidance(discovery) {
|
|
812
|
+
const lines = [];
|
|
813
|
+
const catalogs = discovery.sources.flatMap((source) => (source.uiLabelCatalogs ?? []).map((path) => `${source.name}: ${path}`));
|
|
814
|
+
if (catalogs.length > 0) {
|
|
815
|
+
lines.push(`UI label catalogs (the exact English strings the product displays; read them before naming any button, tab, field, or menu, and quote the displayed value rather than its key):\n${catalogs.map((item) => `- ${item}`).join('\n')}`);
|
|
816
|
+
}
|
|
817
|
+
const partial = discovery.sources.filter((source) => source.truncated);
|
|
818
|
+
if (partial.length > 0) {
|
|
819
|
+
lines.push(`Partial inventory: ${partial.map((source) => `${source.name} (${source.filesScanned} of ${source.filesAvailable} files)`).join(', ')}. Product code was inventoried first; read the source directly for any surface the inventory may have cut off before deciding it does not exist.`);
|
|
820
|
+
}
|
|
821
|
+
return lines.length ? `${lines.join('\n\n')}\n` : '';
|
|
822
|
+
}
|
|
823
|
+
//# sourceMappingURL=source-discovery.js.map
|