@produtype/core 0.8.2 → 0.10.2

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/README.md CHANGED
@@ -174,7 +174,7 @@ prodkit plan ../my-app --output prodkit-plan.md
174
174
 
175
175
  _Generated from the analyzer itself — run `npm run docs:stacks` after changing a detector._
176
176
 
177
- - **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, Litestar, Sanic, Tornado, aiohttp, Starlette, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, .NET
177
+ - **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, Litestar, Sanic, Tornado, aiohttp, Starlette, Streamlit, Gradio, Dash, Chainlit, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, .NET
178
178
  - **Frontend:** React, Vite, Vue, Nuxt, Svelte, Angular, Astro, Solid, Qwik, Preact, Remix, htmx, Tailwind CSS, Electron
179
179
  - **Mobile:** Flutter, React Native, iOS (native), Android (native)
180
180
  - **Databases:** Postgres, MySQL, SQLite, SQL Server, MongoDB, Redis, Firestore, DynamoDB, Convex
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.analyzeProject = analyzeProject;
37
+ const detectPackaging_1 = require("./detectPackaging");
38
+ const importedDependencies_1 = require("./importedDependencies");
37
39
  const path = __importStar(require("path"));
38
40
  const zod_1 = require("zod");
39
41
  const fileScanner_1 = require("../utils/fileScanner");
@@ -151,7 +153,20 @@ function isTestOrExamplePath(file) {
151
153
  * application is not fully understood by being readable — but being readable is the
152
154
  * difference between a partial reading and none.
153
155
  */
154
- const SOURCE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|php|go|rb|java|cs|rs|kt|swift|dart)$/;
156
+ /**
157
+ * `.html` is here, and it is not decoration.
158
+ *
159
+ * Without it a repository whose entire product is one `index.html` with an inline
160
+ * `<script>` — a browser game, a static site, a prototype — was reported as having zero
161
+ * source files, no stack, and a score capped at 39 for being unreadable. `PhaserJS-
162
+ * Spacegame` is exactly that: a complete game, Phaser loaded from a CDN, and nothing
163
+ * for the analyzer to look at. A hardcoded key in an inline script is also a real
164
+ * finding, and every text search here was skipping the file it would be in.
165
+ *
166
+ * `.vue`, `.svelte` and `.astro` were missing for the same reason: a single-file
167
+ * component holds the logic, not just the markup.
168
+ */
169
+ const SOURCE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|php|go|rb|java|cs|rs|kt|swift|dart|html?|vue|svelte|astro)$/;
155
170
  function pickSource(files) {
156
171
  return files.filter((f) => SOURCE_EXTENSIONS.test(f) && !isTestOrExamplePath(f));
157
172
  }
@@ -503,6 +518,30 @@ async function analyzeProject(projectPath) {
503
518
  mergeDeps(npmDeps, workspace.packageJson?.dependencies);
504
519
  mergeDeps(npmDeps, workspace.packageJson?.devDependencies);
505
520
  }
521
+ /**
522
+ * What the code imports, when nothing declared it.
523
+ *
524
+ * Only reached when the manifests for that language produced nothing at all, so a
525
+ * declared dependency is never overridden by a guess at an import. Seventeen
526
+ * repositories in the verification corpus were called unreadable on the strength of a
527
+ * missing manifest while stating their dependencies in the first three lines of their
528
+ * only source file.
529
+ */
530
+ const inferredDependencySources = [];
531
+ if (pythonDeps.length === 0) {
532
+ const imported = await (0, importedDependencies_1.pythonImports)(root, sourceFiles);
533
+ if (imported.length > 0) {
534
+ pythonDeps.push(...imported);
535
+ inferredDependencySources.push('Python imports');
536
+ }
537
+ }
538
+ if (Object.keys(npmDeps).length === 0) {
539
+ const imported = await (0, importedDependencies_1.browserImports)(root, sourceFiles);
540
+ for (const name of imported)
541
+ npmDeps[name] = 'imported';
542
+ if (imported.length > 0)
543
+ inferredDependencySources.push('script tags and module imports');
544
+ }
506
545
  const workspaceStacks = workspaces.map((workspace) => {
507
546
  const wsNpmDeps = {};
508
547
  mergeDeps(wsNpmDeps, workspace.packageJson?.dependencies);
@@ -538,7 +577,7 @@ async function analyzeProject(projectPath) {
538
577
  npmDeps,
539
578
  workspaces,
540
579
  };
541
- const [pm, frontend, backend, database, docker, env, auth, security, uploads, gdpr, billing, observability, jobs, marketplace, aiSafety, engagement, deployment, audit, game, clientLogic, mobile, errorReporting] = await Promise.all([
580
+ const [pm, frontend, backend, database, docker, env, auth, security, uploads, gdpr, billing, observability, jobs, marketplace, aiSafety, engagement, deployment, audit, game, clientLogic, mobile, errorReporting, packaging] = await Promise.all([
542
581
  (0, detectPackageManager_1.detectPackageManager)(ctx),
543
582
  (0, detectFrontend_1.detectFrontend)(ctx),
544
583
  (0, detectBackend_1.detectBackend)(ctx),
@@ -561,6 +600,7 @@ async function analyzeProject(projectPath) {
561
600
  (0, detectClientLogic_1.detectClientLogic)(ctx),
562
601
  (0, detectMobile_1.detectMobile)(ctx),
563
602
  (0, detectErrorReporting_1.detectErrorReporting)(ctx),
603
+ (0, detectPackaging_1.detectPackaging)(ctx),
564
604
  ]);
565
605
  const detectors = (0, detectStack_1.mergeDetectors)([
566
606
  pm.result,
@@ -573,6 +613,7 @@ async function analyzeProject(projectPath) {
573
613
  clientLogic,
574
614
  ...mobile,
575
615
  errorReporting,
616
+ ...packaging,
576
617
  docker,
577
618
  ...env,
578
619
  ...auth,
@@ -608,6 +649,12 @@ async function analyzeProject(projectPath) {
608
649
  warnings: [
609
650
  ...pm.warnings,
610
651
  ...unreadableLanguages(allFiles).map((entry) => `${entry.files} ${entry.language} files were not analysed: this reading covers only part of the repository`),
652
+ // Said out loud, because a dependency nobody declared is a weaker fact than one
653
+ // that is pinned in a lockfile, and the reader is entitled to know which of the
654
+ // two this reading rests on.
655
+ ...(inferredDependencySources.length > 0
656
+ ? [`No dependency manifest was found: dependencies were read from ${inferredDependencySources.join(' and ')}, so versions are unknown.`]
657
+ : []),
611
658
  ],
612
659
  workspaces: workspaceStacks,
613
660
  files: allFiles,
@@ -71,6 +71,14 @@ exports.PYTHON_BACKEND_FRAMEWORKS = [
71
71
  ['tornado', 'tornado'],
72
72
  ['aiohttp', 'aiohttp'],
73
73
  ['starlette', 'starlette'],
74
+ /**
75
+ * These serve an application over HTTP without calling themselves web frameworks,
76
+ * and a repository built on one was reported as having no backend at all.
77
+ */
78
+ ['streamlit', 'streamlit'],
79
+ ['gradio', 'gradio'],
80
+ ['dash', 'dash'],
81
+ ['chainlit', 'chainlit'],
74
82
  ];
75
83
  /** Frontend frameworks settled by their signature dependency. */
76
84
  exports.FRONTEND_FRAMEWORKS = [
@@ -109,6 +117,11 @@ exports.LANGUAGES = [
109
117
  ];
110
118
  /** Display names for ids the tables above produce. */
111
119
  const LABELS = {
120
+ streamlit: 'Streamlit',
121
+ gradio: 'Gradio',
122
+ dash: 'Dash',
123
+ chainlit: 'Chainlit',
124
+ html: 'HTML (no framework)',
112
125
  next: 'Next.js',
113
126
  nestjs: 'NestJS',
114
127
  fastify: 'Fastify',
@@ -24,7 +24,15 @@ async function detectBilling(ctx) {
24
24
  * a customer or subscription id, or a webhook path. One that only ever writes
25
25
  * "Stripe" in prose is talking about Stripe, not billing through it.
26
26
  */
27
- /STRIPE_[A-Z0-9_]+/,
27
+ /**
28
+ * A Stripe credential, not any constant whose name begins with those six letters.
29
+ *
30
+ * `/STRIPE_[A-Z0-9_]+/` matched `STRIPE_LEN` in Unity's bundled xxHash3, where a
31
+ * stripe is a block of bytes being hashed, and reported a game as taking
32
+ * subscriptions. The word is only evidence of billing when it names a key, a
33
+ * secret, a token or an id.
34
+ */
35
+ /STRIPE_(?:[A-Z0-9_]*_)?(?:KEY|SECRET|TOKEN|ID|WEBHOOK|PRICE|ACCOUNT|API)[A-Z0-9_]*/,
28
36
  /stripeCustomerId/i,
29
37
  /stripeSubscriptionId/i,
30
38
  /\/webhooks?\/stripe/i,
@@ -71,11 +71,34 @@ async function detectClientLogic(ctx) {
71
71
  for (const hit of drawing) {
72
72
  evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line });
73
73
  }
74
+ /**
75
+ * A render loop. The application is deciding what the screen shows, frame by frame.
76
+ *
77
+ * Counted here because canvas drawing on its own was one signal short of the two this
78
+ * detector needs, so half a dozen browser games in the verification corpus came back
79
+ * as `static-site` — a brochure page — when their whole product is a loop over a
80
+ * canvas. A marketing page that animates on scroll uses the same call and has no
81
+ * canvas, so it still reaches only one signal and is still a static site.
82
+ */
83
+ const frameLoop = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source,
84
+ /**
85
+ * `setInterval` is deliberately absent. It was here for one revision and was wrong:
86
+ * it matched a countdown in a React modal and a polling sync in an API client,
87
+ * neither of which is the application deciding what the screen shows. Both projects
88
+ * were real products that this demoted to plain client applications, and a demoted
89
+ * profile is judged by fewer expectations — so the change quietly raised their
90
+ * scores. `requestAnimationFrame` means a render loop and nothing else.
91
+ */
92
+ [/requestAnimationFrame\s*\(/], 6);
93
+ for (const hit of frameLoop) {
94
+ evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line });
95
+ }
74
96
  const signals = [
75
97
  stateDeps.length > 0,
76
98
  logicDirs.length > 0,
77
99
  persistence.length > 0 || dartStorage.length > 0,
78
100
  drawing.length > 0,
101
+ frameLoop.length > 0,
79
102
  ].filter(Boolean).length;
80
103
  return {
81
104
  key: 'stack.clientLogic',
@@ -83,6 +106,6 @@ async function detectClientLogic(ctx) {
83
106
  // banner writing to localStorage, or a marketing page with a hero canvas.
84
107
  present: signals >= 2,
85
108
  evidence,
86
- details: { stateDeps, logicDirs: logicDirs.length, persistence: persistence.length + dartStorage.length, drawing: drawing.length, signals },
109
+ details: { stateDeps, logicDirs: logicDirs.length, persistence: persistence.length + dartStorage.length, drawing: drawing.length, frameLoop: frameLoop.length, signals },
87
110
  };
88
111
  }
@@ -34,6 +34,25 @@ async function detectFrontend(ctx) {
34
34
  frameworks.push('flutter');
35
35
  evidence.push({ type: 'dependency', value: 'flutter' });
36
36
  }
37
+ /**
38
+ * A page is a front end, whether or not a framework built it.
39
+ *
40
+ * This list only ever held frameworks, so a repository whose product is `index.html`
41
+ * plus a script had no front end, no stack, and a report that called it unreadable
42
+ * and capped its score at 39. Six of them sit in the verification corpus — browser
43
+ * games and small static sites, each one perfectly legible.
44
+ *
45
+ * Recorded only when no framework claimed the project, because saying "react, html"
46
+ * about a React application is noise: every one of them ships an `index.html`.
47
+ */
48
+ if (frameworks.length === 0) {
49
+ const pages = ctx.files.source.filter((file) => /\.html?$/i.test(file));
50
+ if (pages.length > 0) {
51
+ frameworks.push('html');
52
+ for (const page of pages.slice(0, 3))
53
+ evidence.push({ type: 'file', value: page, file: page });
54
+ }
55
+ }
37
56
  const extras = (0, detectContext_1.hasAnyDep)(ctx, ['react-router-dom', 'axios', 'tailwindcss', 'electron']);
38
57
  for (const d of extras) {
39
58
  frameworks.push(d);
@@ -92,8 +92,23 @@ const OFFLINE_DEPS = [
92
92
  '@tanstack/query-persist-client-core',
93
93
  'powersync',
94
94
  ];
95
+ /**
96
+ * Paths that contain a platform marker without being a project.
97
+ *
98
+ * Every macOS `.bundle`, `.framework`, `.app` and `.dSYM` carries an `Info.plist` by
99
+ * definition, and build caches are full of them. A Unity game was classified as an iOS
100
+ * application on the strength of
101
+ * `Library/BurstCache/JIT/…bundle.dSYM/Contents/Info.plist` — a debug-symbols bundle
102
+ * inside generated output, in a repository whose own engine had already been detected.
103
+ *
104
+ * `Pods`, `DerivedData` and `node_modules` are here for the same reason: they hold
105
+ * other people's projects, and finding one there says nothing about this one.
106
+ */
107
+ const GENERATED_OR_VENDORED = /(^|\/)(Library|Pods|DerivedData|build|node_modules|dist|out|\.gradle)(\/|$)|\.(bundle|framework|app|dSYM|xcframework)\//i;
95
108
  function matchesAny(files, patterns) {
96
- return files.filter((file) => patterns.some((pattern) => pattern.test(file)));
109
+ return files
110
+ .filter((file) => !GENERATED_OR_VENDORED.test(file))
111
+ .filter((file) => patterns.some((pattern) => pattern.test(file)));
97
112
  }
98
113
  async function detectMobile(ctx) {
99
114
  const iosFiles = matchesAny(ctx.files.all, IOS_MARKERS);
@@ -8,18 +8,31 @@ async function detectObservability(ctx) {
8
8
  const logDeps = (0, detectContext_1.hasAnyDep)(ctx, ['winston', 'pino', 'morgan', 'bunyan']);
9
9
  const sentryDeps = (0, detectContext_1.hasAnyDep)(ctx, ['@sentry/node', 'sentry-sdk']);
10
10
  for (const d of logDeps)
11
- evidence.push({ type: 'dependency', value: d });
11
+ evidence.push({ type: 'dependency', value: d, claim: 'logging' });
12
12
  for (const d of sentryDeps)
13
- evidence.push({ type: 'dependency', value: d });
13
+ evidence.push({ type: 'dependency', value: d, claim: 'logging' });
14
14
  const hits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/\/(health|healthz|readyz)\b/i, /x-request-id/i, /correlation-id/i, /error\s*handler/i, /RotatingFileHandler/i], 25);
15
- for (const m of hits)
16
- evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
15
+ /**
16
+ * One search, three answers, so the claim is decided per hit rather than per search.
17
+ *
18
+ * A `logger.debug(...)` line was being cited as the evidence for a missing health
19
+ * endpoint, which is not an argument about health at all. Whichever pattern matched
20
+ * is what the line is evidence of.
21
+ */
22
+ for (const m of hits) {
23
+ const claim = /\/(health|healthz|readyz)\b/i.test(m.snippet)
24
+ ? 'health'
25
+ : /x-request-id|correlation-id/i.test(m.snippet)
26
+ ? 'request-id'
27
+ : 'logging';
28
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim });
29
+ }
17
30
  // A file-routing framework declares the endpoint as a path, not as a string in
18
31
  // source: Next's app/api/health/route.ts contains no "/health" to match. Searching
19
32
  // only the text reports hardened applications as having no health check.
20
33
  const healthFiles = ctx.files.all.filter((file) => /(^|\/)(health|healthz|readyz|liveness|readiness)(\/route|\.[a-z]+)?$|(^|\/)(health|healthz|readyz)\//i.test(file));
21
34
  for (const file of healthFiles)
22
- evidence.push({ type: 'file', value: file });
35
+ evidence.push({ type: 'file', value: file, claim: 'health' });
23
36
  const hasHealth = healthFiles.length > 0 || hits.some((h) => /\/(health|healthz|readyz)\b/i.test(h.snippet));
24
37
  const hasReqId = hits.some((h) => /x-request-id|correlation-id/i.test(h.snippet));
25
38
  // Structured logging without a logging library is still structured logging. What
@@ -27,7 +40,7 @@ async function detectObservability(ctx) {
27
40
  // produced them.
28
41
  const structuredHits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/JSON\.stringify\(\s*\{[^}]*level/i, /logger\.(info|warn|error|debug)\s*\(/, /structuredLog/i], 15);
29
42
  for (const m of structuredHits)
30
- evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
43
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'logging' });
31
44
  const hasStructuredLogging = logDeps.length > 0 || structuredHits.length > 0;
32
45
  return {
33
46
  key: 'observability.core',
@@ -0,0 +1,3 @@
1
+ import type { DetectorResult } from './types';
2
+ import type { DetectContext } from './detectContext';
3
+ export declare function detectPackaging(ctx: DetectContext): Promise<DetectorResult[]>;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectPackaging = detectPackaging;
4
+ const readTextFileSafe_1 = require("../utils/readTextFileSafe");
5
+ /**
6
+ * Whether a project is fit to be installed and depended on by someone else.
7
+ *
8
+ * Twenty-five of the seventy-seven repositories in the verification corpus received no
9
+ * profile at all, and a large share of them were libraries and command-line tools:
10
+ * Meta's llama3 reference implementation, a Markdown converter, a photo editor, this
11
+ * project's own AI package. Every profile here assumed a running service with users, so
12
+ * the most common kind of code on a developer's disk had no category — and a product
13
+ * that sells "judged for the type of product it is" said nothing about what they were.
14
+ *
15
+ * What matters for a library is not what matters for a service. Nobody signs in to a
16
+ * package. What decides whether it is fit to publish is whether it says what it is,
17
+ * whether it can be imported, whether its licence permits use at all, and whether
18
+ * anything proves it works.
19
+ */
20
+ const LICENSE_FILE = /(^|\/)(LICEN[CS]E|COPYING)(\.[a-z]+)?$/i;
21
+ const README_FILE = /(^|\/)README(\.[a-z]+)?$/i;
22
+ const CI_FILE = /(^|\/)(\.github\/workflows\/[^/]+\.ya?ml|\.gitlab-ci\.yml|\.circleci\/config\.yml|azure-pipelines\.yml|Jenkinsfile|\.travis\.yml|\.woodpecker\.ya?ml)$/i;
23
+ /**
24
+ * A test, as opposed to a file with "test" in its name.
25
+ *
26
+ * Deliberately not `/test/`: `src/latest.ts` and `contest.py` both contain it, and a
27
+ * repository does not get credit for a word.
28
+ */
29
+ const TEST_FILE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)test_[^/]+\.py$|[^/]+_test\.(py|go|rb)$/i;
30
+ function collect(files, pattern, limit = 3) {
31
+ return files.filter((file) => pattern.test(file)).slice(0, limit);
32
+ }
33
+ async function detectPackaging(ctx) {
34
+ const all = ctx.files.all;
35
+ const licenseFiles = collect(all, LICENSE_FILE);
36
+ const declaredLicense = typeof ctx.packageJson?.license === 'string';
37
+ const licenseEvidence = [
38
+ ...licenseFiles.map((file) => ({ type: 'file', value: file, file })),
39
+ ...(declaredLicense ? [{ type: 'note', value: 'a license field in package.json' }] : []),
40
+ ];
41
+ const readmeFiles = collect(all, README_FILE);
42
+ /**
43
+ * A README that is only a title is not documentation.
44
+ *
45
+ * The threshold is deliberately low — enough prose to say what the thing is and how
46
+ * to install it — because the check is "did anyone write anything", not "is it good".
47
+ */
48
+ let readmeLength = 0;
49
+ for (const file of readmeFiles) {
50
+ const raw = await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file);
51
+ readmeLength = Math.max(readmeLength, (raw ?? '').trim().length);
52
+ }
53
+ const ciFiles = collect(all, CI_FILE);
54
+ const testFiles = all.filter((file) => TEST_FILE.test(file));
55
+ /**
56
+ * A test runner that something actually invokes.
57
+ *
58
+ * Test files with no way to run them is a different state from a project with a
59
+ * `npm test` that a reader can type, so the two are separated: files alone are
60
+ * `partial`, files plus a runner are `complete`.
61
+ */
62
+ const scripts = (ctx.packageJson?.scripts ?? {});
63
+ const hasTestScript = typeof scripts.test === 'string' && !/no test specified/i.test(scripts.test);
64
+ const hasPyTestRunner = all.some((f) => /(^|\/)(pytest\.ini|tox\.ini|noxfile\.py)$/i.test(f))
65
+ || ctx.pythonDeps.some((d) => d === 'pytest' || d === 'nose2' || d === 'unittest2');
66
+ /**
67
+ * Whether the package can be installed and imported.
68
+ *
69
+ * `main`, `module`, `exports` and `bin` are the four ways a Node package says where
70
+ * it starts; `types` is how a TypeScript consumer finds it. A Python package says the
71
+ * same thing through `[project.scripts]` or a `packages` argument in setup.py.
72
+ */
73
+ const pkg = (ctx.packageJson ?? {});
74
+ const nodeEntrypoints = ['main', 'module', 'exports', 'bin'].filter((key) => pkg[key] !== undefined);
75
+ const hasTypes = pkg.types !== undefined || pkg.typings !== undefined;
76
+ const isPrivate = pkg.private === true;
77
+ let pythonEntrypoints = false;
78
+ for (const file of all.filter((f) => /(^|\/)(pyproject\.toml|setup\.py|setup\.cfg)$/i.test(f)).slice(0, 4)) {
79
+ const raw = (await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file)) ?? '';
80
+ if (/\[project\.scripts\]|\[project\.gui-scripts\]|console_scripts|entry_points|packages\s*=|\[project\]/i.test(raw)) {
81
+ pythonEntrypoints = true;
82
+ }
83
+ }
84
+ const hasManifest = ctx.packageJson !== null
85
+ || all.some((f) => /(^|\/)(pyproject\.toml|setup\.py|go\.mod|Cargo\.toml|composer\.json|\w+\.gemspec)$/i.test(f));
86
+ /**
87
+ * A name and a version. Without them nothing can depend on this, whatever else it
88
+ * does right.
89
+ */
90
+ const named = typeof pkg.name === 'string' && typeof pkg.version === 'string';
91
+ const described = typeof pkg.description === 'string' && pkg.description.length > 0;
92
+ const sourced = pkg.repository !== undefined || pkg.homepage !== undefined;
93
+ const entrypointCount = nodeEntrypoints.length + (pythonEntrypoints ? 1 : 0);
94
+ return [
95
+ {
96
+ key: 'packaging.manifest',
97
+ present: hasManifest,
98
+ complete: hasManifest && (named || pythonEntrypoints),
99
+ evidence: hasManifest ? [{ type: 'note', value: named ? 'a manifest with a name and a version' : 'a package manifest' }] : [],
100
+ details: { named, described, sourced, private: isPrivate },
101
+ },
102
+ {
103
+ key: 'packaging.entrypoints',
104
+ present: entrypointCount > 0,
105
+ complete: entrypointCount > 0 && (hasTypes || pythonEntrypoints),
106
+ evidence: [
107
+ ...nodeEntrypoints.map((key) => ({ type: 'note', value: `package.json declares "${key}"` })),
108
+ ...(pythonEntrypoints ? [{ type: 'note', value: 'a Python package or console script declaration' }] : []),
109
+ ...(hasTypes ? [{ type: 'note', value: 'TypeScript types are declared' }] : []),
110
+ ],
111
+ details: { nodeEntrypoints, hasTypes, pythonEntrypoints },
112
+ },
113
+ {
114
+ key: 'packaging.license',
115
+ // A licence file and a declared licence say the same thing; either alone is an
116
+ // answer, and a reader who has one does not need the other to use the code.
117
+ present: licenseFiles.length > 0 || declaredLicense,
118
+ complete: licenseFiles.length > 0 && declaredLicense,
119
+ evidence: licenseEvidence,
120
+ details: { files: licenseFiles.length, declared: declaredLicense },
121
+ },
122
+ {
123
+ key: 'docs.readme',
124
+ present: readmeFiles.length > 0,
125
+ complete: readmeLength > 400,
126
+ evidence: readmeFiles.map((file) => ({ type: 'file', value: `${file} (${readmeLength} characters)`, file })),
127
+ details: { length: readmeLength },
128
+ },
129
+ {
130
+ key: 'quality.tests',
131
+ present: testFiles.length > 0,
132
+ complete: testFiles.length > 0 && (hasTestScript || hasPyTestRunner),
133
+ evidence: [
134
+ ...testFiles.slice(0, 3).map((file) => ({ type: 'file', value: file, file })),
135
+ ...(hasTestScript ? [{ type: 'note', value: 'an "npm test" script' }] : []),
136
+ ...(hasPyTestRunner ? [{ type: 'note', value: 'a Python test runner' }] : []),
137
+ ],
138
+ details: { files: testFiles.length, runner: hasTestScript || hasPyTestRunner },
139
+ },
140
+ {
141
+ key: 'quality.ci',
142
+ present: ciFiles.length > 0,
143
+ complete: ciFiles.length > 0 && testFiles.length > 0,
144
+ evidence: ciFiles.map((file) => ({ type: 'file', value: file, file })),
145
+ details: { files: ciFiles.length },
146
+ },
147
+ ];
148
+ }
@@ -66,13 +66,13 @@ async function detectSecurity(ctx) {
66
66
  const rateLimitSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/rateLimit\s*\(/, /rate_?limit/i, /Retry-After/i, /\b429\b/, /TooManyRequests/i], 20);
67
67
  const rateLimit = rateLimitDep || rateLimitSignals.length > 0;
68
68
  if (helmetDep)
69
- evidence.push({ type: 'dependency', value: 'helmet' });
69
+ evidence.push({ type: 'dependency', value: 'helmet', claim: 'headers' });
70
70
  if (rateLimitDep)
71
- evidence.push({ type: 'dependency', value: 'rate limiting package' });
71
+ evidence.push({ type: 'dependency', value: 'rate limiting package', claim: 'rate-limit' });
72
72
  for (const m of headerSignals)
73
- evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
73
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'headers' });
74
74
  for (const m of rateLimitSignals)
75
- evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
75
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'rate-limit' });
76
76
  const corsLoose = [];
77
77
  const corsStrict = [];
78
78
  for (const file of source) {
@@ -96,18 +96,18 @@ async function detectSecurity(ctx) {
96
96
  corsStrict.push(...detected.strict);
97
97
  }
98
98
  for (const m of corsLoose)
99
- evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
99
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'cors' });
100
100
  for (const m of corsStrict)
101
- evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
101
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'cors' });
102
102
  const webhookSig = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/constructEvent\(/, /webhook.*signature/i], 10);
103
103
  const bodyLimit = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/express\.json\(\s*\{[^}]*limit\s*:/i], 10);
104
104
  const contentTypeCheck = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/content-type/i, /req\.is\(/], 10);
105
105
  for (const m of webhookSig)
106
- evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
106
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'webhook-signature' });
107
107
  for (const m of bodyLimit)
108
- evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
108
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'body-size' });
109
109
  for (const m of contentTypeCheck)
110
- evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
110
+ evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'content-type' });
111
111
  const djangoSettings = ctx.files.all.find((f) => f.endsWith('settings.py'));
112
112
  let djangoDebugTrue = false;
113
113
  let djangoSecureCookies = true;
@@ -115,15 +115,15 @@ async function detectSecurity(ctx) {
115
115
  const text = (await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, djangoSettings)) ?? '';
116
116
  if (/DEBUG\s*=\s*True/.test(text)) {
117
117
  djangoDebugTrue = true;
118
- evidence.push({ type: 'snippet', value: 'DEBUG = True', file: djangoSettings });
118
+ evidence.push({ type: 'snippet', value: 'DEBUG = True', file: djangoSettings, claim: 'django-debug' });
119
119
  }
120
120
  const insecureSession = /SESSION_COOKIE_SECURE\s*=\s*False/.test(text);
121
121
  const insecureCsrf = /CSRF_COOKIE_SECURE\s*=\s*False/.test(text);
122
122
  djangoSecureCookies = !(insecureSession || insecureCsrf);
123
123
  if (insecureSession)
124
- evidence.push({ type: 'snippet', value: 'SESSION_COOKIE_SECURE = False', file: djangoSettings });
124
+ evidence.push({ type: 'snippet', value: 'SESSION_COOKIE_SECURE = False', file: djangoSettings, claim: 'django-cookies' });
125
125
  if (insecureCsrf)
126
- evidence.push({ type: 'snippet', value: 'CSRF_COOKIE_SECURE = False', file: djangoSettings });
126
+ evidence.push({ type: 'snippet', value: 'CSRF_COOKIE_SECURE = False', file: djangoSettings, claim: 'django-cookies' });
127
127
  }
128
128
  return {
129
129
  key: 'security.core',
@@ -0,0 +1,9 @@
1
+ /** Packages a Python project imports, when no requirements file declared any. */
2
+ export declare function pythonImports(root: string, sourceFiles: string[]): Promise<string[]>;
3
+ /**
4
+ * Packages a browser project loads, when no package.json declared any.
5
+ *
6
+ * A game served as `index.html` plus two scripts has no package manager and never will;
7
+ * its dependency list is the set of `<script src>` tags at the top of the page.
8
+ */
9
+ export declare function browserImports(root: string, sourceFiles: string[]): Promise<string[]>;