@produtype/core 1.9.1 → 1.10.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.
@@ -26,6 +26,36 @@ const blockComments_1 = require("../analyzer/blockComments");
26
26
  * and is left alone.
27
27
  */
28
28
  const PATTERN_DECLARATION = /^(?:const\s+\w+(?:\s*:[^=]+)?\s*=\s*)?\/(?:[^/\\]|\\.)+\/[gimsuy]*\s*[,;]?$/;
29
+ /**
30
+ * A name given a type, rather than a value.
31
+ *
32
+ * pocketbase's report cited three lines for its cross-origin policy: `AllowedOrigins
33
+ * []string` — a field in a Go struct — `var allowedOrigins []string` in the command
34
+ * that parses the flag, and `allowedOrigins: Array<string>` in a generated `.d.ts`.
35
+ * Not one of them is a decision about origins. The line that is,
36
+ * `config.AllowedOrigins = []string{"*"}` twenty lines further down, was never
37
+ * reached, so a reader was given three declarations to argue with instead of the
38
+ * default that actually applies.
39
+ *
40
+ * It is the same rule as the regex table above, for the same reason: a declaration
41
+ * says what a thing is, and this analyzer claims to say what the code does.
42
+ *
43
+ * The right-hand side has to look like a type, not a value, because `AllowOrigins:
44
+ * config.AllowedOrigins,` is a use and reads almost identically. A type starts with a
45
+ * capital or is one of the primitives, and carries no quotes, no call and no digits —
46
+ * and an object-literal member ends in a comma, which a field signature does not.
47
+ */
48
+ /**
49
+ * `import SwiftUI` has the shape and is not a declaration of anything.
50
+ *
51
+ * A keyword followed by a capitalised word reads exactly like a field given a type,
52
+ * and the first version of this rule hid every Swift and Kotlin import — which is how
53
+ * the native toolkits are detected, so an iOS application lost its interface. The
54
+ * statement keywords are excluded by name; `var`, `let` and `readonly` stay, because
55
+ * those really do introduce a declaration.
56
+ */
57
+ const STATEMENT_KEYWORD = /^(?:import|package|from|export|return|case|new|type|class|struct|interface|enum|func|fun|def|public|private|protected|internal|throw|throws|extends|implements|use|using|namespace|module|require|await|yield|delete|typeof|instanceof|in|is|as|if|else|for|while|switch|do|try|catch|finally|with|assert|raise|lambda|val|const)\b/;
58
+ const TYPE_DECLARATION = /^(?:var\s+|let\s+|readonly\s+)?\w+\??\s*(?::\s*|\s+)(?:\[\]|\*|Array<|Map<|\bstring\b|\bnumber\b|\bboolean\b|\bbool\b|\bany\b|\bunknown\b|\bvoid\b|[A-Z])[\w.<>[\]|&\s]*;?$/;
29
59
  /**
30
60
  * Prose about the code, rather than the code.
31
61
  *
@@ -56,9 +86,13 @@ const COMMENT_LINE = /^(?:\/\/|\/\*|\*\/?|#(?![![])(?!!)|<!--|--\s)/;
56
86
  *
57
87
  * Nothing in the shape of the line tells the two apart, so the rule is not made.
58
88
  */
89
+ function declaresAType(trimmed) {
90
+ const withoutDeclarator = trimmed.replace(/^(?:var|let|readonly)\s+/, '');
91
+ return !STATEMENT_KEYWORD.test(withoutDeclarator) && TYPE_DECLARATION.test(trimmed);
92
+ }
59
93
  function declaresRatherThanDoes(line) {
60
94
  const trimmed = line.trim();
61
- return COMMENT_LINE.test(trimmed) || PATTERN_DECLARATION.test(trimmed);
95
+ return COMMENT_LINE.test(trimmed) || PATTERN_DECLARATION.test(trimmed) || declaresAType(trimmed);
62
96
  }
63
97
  /**
64
98
  * Whether this line may be quoted back to the reader as something the code does.
@@ -93,6 +127,54 @@ function isCitableLine(line) {
93
127
  * on is not worth the one it displaces.
94
128
  */
95
129
  const MAX_CITABLE_LINE = 500;
130
+ /**
131
+ * A placeholder is not a value.
132
+ *
133
+ * pocketbase was reported as having tenant boundaries — `passed`, which raises a
134
+ * score — and the only two strong matches in its readable source were
135
+ * `"Ex. https://login.microsoftonline.com/YOUR_DIRECTORY_TENANT_ID/oauth2/v2.0/authorize"`,
136
+ * twice, in the help text of the form where somebody configures Microsoft sign-in.
137
+ * Microsoft Entra calls its directory a tenant; the string is telling a reader where
138
+ * to paste theirs. pocketbase has no organizations at all, and the rest of that
139
+ * finding was Apple's developer `teamId`, a weak word that cannot stand alone.
140
+ *
141
+ * `YOUR_SOMETHING` is the convention for "replace this", in documentation, in example
142
+ * configuration and in the help text beside a field. This analyzer already knows the
143
+ * shape: `your[_-]?secret` has been in the weak-secret list since the beginning.
144
+ *
145
+ * The token around the match is what decides, not the line. A line may hold a
146
+ * placeholder and a real value both, and only the matched one is being judged.
147
+ */
148
+ const PLACEHOLDER_TOKEN = /^(?:your|my|sample|example|placeholder|changeme|todo|xxx+)[_-]/i;
149
+ function insideAPlaceholder(line, index, length) {
150
+ let start = index;
151
+ while (start > 0 && /[A-Za-z0-9_-]/.test(line[start - 1]))
152
+ start--;
153
+ let end = index + length;
154
+ while (end < line.length && /[A-Za-z0-9_-]/.test(line[end]))
155
+ end++;
156
+ return PLACEHOLDER_TOKEN.test(line.slice(start, end));
157
+ }
158
+ /**
159
+ * Where a needle first matches, or -1. Shared so that both searches below judge a
160
+ * match the same way.
161
+ */
162
+ function findNeedle(line, needle) {
163
+ if (typeof needle === 'string') {
164
+ const index = line.indexOf(needle);
165
+ return index === -1 ? null : { index, length: needle.length };
166
+ }
167
+ const found = new RegExp(needle.source, needle.flags.replace('g', '')).exec(line);
168
+ return found ? { index: found.index, length: found[0].length } : null;
169
+ }
170
+ function matchesHere(line, needles) {
171
+ for (const needle of needles) {
172
+ const found = findNeedle(line, needle);
173
+ if (found && !insideAPlaceholder(line, found.index, found.length))
174
+ return true;
175
+ }
176
+ return false;
177
+ }
96
178
  /**
97
179
  * The lines of one already-read file that match, with the same hygiene the file
98
180
  * search applies: no comments, no pattern tables, no minified lines.
@@ -123,12 +205,8 @@ function matchLines(text, needles, file = '') {
123
205
  continue;
124
206
  if (declaresRatherThanDoes(line))
125
207
  continue;
126
- for (const n of needles) {
127
- const hit = typeof n === 'string' ? line.includes(n) : n.test(line);
128
- if (hit) {
129
- matches.push({ file, line: i + 1, snippet: line.trim().slice(0, 200) });
130
- break;
131
- }
208
+ if (matchesHere(line, needles)) {
209
+ matches.push({ file, line: i + 1, snippet: line.trim().slice(0, 200) });
132
210
  }
133
211
  }
134
212
  return matches;
@@ -171,14 +249,10 @@ async function searchInFiles(root, files, needles, limit = 25, keep) {
171
249
  continue;
172
250
  if (declaresRatherThanDoes(line))
173
251
  continue;
174
- for (const n of needles) {
175
- const hit = typeof n === 'string' ? line.includes(n) : n.test(line);
176
- if (hit) {
177
- const match = { file, line: i + 1, snippet: line.trim().slice(0, 200) };
178
- if (!keep || keep(match))
179
- matches.push(match);
180
- break;
181
- }
252
+ if (matchesHere(line, needles)) {
253
+ const match = { file, line: i + 1, snippet: line.trim().slice(0, 200) };
254
+ if (!keep || keep(match))
255
+ matches.push(match);
182
256
  }
183
257
  if (matches.length >= limit)
184
258
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "description": "Deterministic CLI and library that analyzes a web application repository and reports how far it is from production-ready for the kind of product it is meant to be.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "docs:stacks": "npm run build && node scripts/sync-readme-stacks.mjs",
38
38
  "prepare": "npm run build",
39
39
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
40
- "prepublishOnly": "npm run build && node scripts/assert-no-ai.mjs && npm test"
40
+ "prepublishOnly": "npm run build && npm run lint && node scripts/assert-no-ai.mjs && npm test"
41
41
  },
42
42
  "engines": {
43
43
  "node": ">=18"