tspl-cli 0.0.0-stage → 0.1.1

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.
Files changed (75) hide show
  1. package/README.md +127 -2
  2. package/bin/tspl.js +10 -0
  3. package/package.json +41 -4
  4. package/src/active-org.js +49 -0
  5. package/src/analysis/analyzer.js +61 -0
  6. package/src/analysis/extract.js +37 -0
  7. package/src/analysis/file-filter.js +69 -0
  8. package/src/analysis/git-reader.js +108 -0
  9. package/src/analysis/html-parser.js +46 -0
  10. package/src/analysis/js-parser.js +473 -0
  11. package/src/analysis/py-parser.js +66 -0
  12. package/src/analysis/repo-index.js +107 -0
  13. package/src/api-client.js +108 -0
  14. package/src/browser-tests.js +209 -0
  15. package/src/commands/analyze.command.js +81 -0
  16. package/src/commands/auth.command.js +250 -0
  17. package/src/commands/build.command.js +267 -0
  18. package/src/commands/connect.command.js +270 -0
  19. package/src/commands/engines.command.js +136 -0
  20. package/src/commands/find.command.js +107 -0
  21. package/src/commands/init.command.js +79 -0
  22. package/src/commands/link.command.js +65 -0
  23. package/src/commands/mcp.command.js +12 -0
  24. package/src/commands/member.command.js +82 -0
  25. package/src/commands/plan.command.js +161 -0
  26. package/src/commands/project.command.js +191 -0
  27. package/src/commands/setup.command.js +63 -0
  28. package/src/commands/sync.command.js +59 -0
  29. package/src/commands/task.command.js +475 -0
  30. package/src/commands/usage.command.js +105 -0
  31. package/src/config-store.js +255 -0
  32. package/src/connect/claude-skill.md +30 -0
  33. package/src/engines.js +218 -0
  34. package/src/find/graph.js +272 -0
  35. package/src/find/search.js +473 -0
  36. package/src/find/text.js +86 -0
  37. package/src/git-helper.js +102 -0
  38. package/src/index.js +91 -0
  39. package/src/mcp/server.js +708 -0
  40. package/src/project-config.js +197 -0
  41. package/src/project-init.js +447 -0
  42. package/src/providers/github.js +249 -0
  43. package/src/providers/index.js +83 -0
  44. package/src/runtime-check.js +18 -0
  45. package/src/setup.js +180 -0
  46. package/src/shared/approaches.js +44 -0
  47. package/src/shared/constants.js +89 -0
  48. package/src/shared/index.js +8 -0
  49. package/src/shared/workflow-progress.js +67 -0
  50. package/src/shared/workflow.js +381 -0
  51. package/src/stack-detector.js +47 -0
  52. package/src/task/approaches.js +53 -0
  53. package/src/task/branch-choice.js +69 -0
  54. package/src/task/brief.js +115 -0
  55. package/src/task/build.js +549 -0
  56. package/src/task/check.js +415 -0
  57. package/src/task/conventions.js +113 -0
  58. package/src/task/gate.js +88 -0
  59. package/src/task/hooks.js +82 -0
  60. package/src/task/plan-draft.js +43 -0
  61. package/src/task/plan-page.js +123 -0
  62. package/src/task/plan.js +293 -0
  63. package/src/task/planning-context.js +165 -0
  64. package/src/task/refine.js +73 -0
  65. package/src/workflow/agents.js +336 -0
  66. package/src/workflow/checkout-lock.js +92 -0
  67. package/src/workflow/client.js +82 -0
  68. package/src/workflow/controller.js +585 -0
  69. package/src/workflow/evidence.js +276 -0
  70. package/src/workflow/pr-body.js +52 -0
  71. package/src/workflow/process.js +82 -0
  72. package/src/workflow/review.js +128 -0
  73. package/src/workflow/runtime.js +148 -0
  74. package/src/workflow/wait.js +58 -0
  75. package/src/workspace.js +250 -0
package/README.md CHANGED
@@ -1,3 +1,128 @@
1
- # Temporary Holding Version
1
+ # tspl
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `tspl` is the command-line tool for the TSPL AI feature-development tool. It reads your repositories (no AI, nothing leaves your machine), turns a task into a clear brief, plans the change with you, enforces the approved plan while an AI tool builds it, runs the tests and opens the pull request.
4
+
5
+ ```text
6
+ npm i -g tspl-cli → tspl setup → tspl init → tspl task new "…" → tspl t-xxxx → tspl plan approve t-xxxx → tspl task run t-xxxx
7
+ ```
8
+
9
+ ## Requirements
10
+
11
+ - Node 22 or newer
12
+ - git
13
+ - A TSPL account and the address of your TSPL API (ask your team)
14
+ - An AI tool such as Claude Code, Codex, Cursor or Gemini, using your own subscription
15
+
16
+ ## 1. Install (once per computer)
17
+
18
+ ```bash
19
+ npm install -g tspl-cli
20
+ tspl --version
21
+ ```
22
+
23
+ Update later with `npm install -g tspl-cli@latest`.
24
+
25
+ ## 2. Configure (once per computer)
26
+
27
+ ```bash
28
+ tspl setup --api https://<your-tspl-api> --connect claude
29
+ tspl whoami
30
+ ```
31
+
32
+ `tspl setup` walks you through:
33
+
34
+ - logging in to the TSPL API with your email and password
35
+ - choosing which AI engine does each step
36
+ - connecting your AI tool: the `/tspl` command, the edit lock and the MCP plug-in
37
+
38
+ Connect other tools with `--connect codex cursor gemini antigravity vscode`, or later with `tspl connect <tool>`. Restart your AI tool after connecting it.
39
+
40
+ Several organizations:
41
+
42
+ ```bash
43
+ tspl login --org <slug> # log in to a specific organization
44
+ tspl org use <slug> # switch to one you are already logged in to
45
+ ```
46
+
47
+ ## 3. Set up a project (once per project folder)
48
+
49
+ Run inside the project folder:
50
+
51
+ ```bash
52
+ cd path/to/your/project
53
+ tspl init
54
+ ```
55
+
56
+ This links the folder to a TSPL project, finds its test and lint checks, and saves `.tspl/project.json`. Commit that file so the whole team shares the same rules. tspl then reads the code into a local map (no AI, nothing uploaded).
57
+
58
+ Running `tspl` with no arguments sets the folder up if needed, or shows its open tasks.
59
+
60
+ ## 4. Work on a task
61
+
62
+ ```bash
63
+ tspl task new "Add a Contact page at /contact" # answer a few questions; prints the task id, e.g. t-xxxx
64
+ tspl t-xxxx # compare approaches, pick one, write the plan
65
+ tspl plan show t-xxxx # files to edit, create and remove, and tests
66
+ tspl plan approve t-xxxx # developer approves, then the PM approves
67
+ tspl task run t-xxxx # code, run checks, repair, review, draft pull requests, CI
68
+ ```
69
+
70
+ Other ways to start a task:
71
+
72
+ ```bash
73
+ tspl task new --ticket TEST-1 "Add About page" # with a ticket id
74
+ tspl task new --file ticket.txt # ticket text from a file
75
+ ```
76
+
77
+ `tspl task run` asks which branch each repo starts from and the name of the new branch. Skip the questions with:
78
+
79
+ ```bash
80
+ tspl task run t-xxxx --base main --branch feature/contact-page
81
+ ```
82
+
83
+ Run `tspl task run t-xxxx` again to resume after a stop. To retry one stage, add `--stage testing` or `--stage reviewing`.
84
+
85
+ From your AI tool, type `/tspl <what should change>` to do the same steps there.
86
+
87
+ ## Everyday commands
88
+
89
+ | What | Command |
90
+ | --- | --- |
91
+ | Where a task is and the next command | `tspl task status t-xxxx` |
92
+ | Tasks on this computer | `tspl task list` |
93
+ | Show a task brief | `tspl task show t-xxxx` |
94
+ | Change a task | `tspl task edit t-xxxx` |
95
+ | Plans waiting for a decision | `tspl plan list` |
96
+ | Send a plan back | `tspl plan reject t-xxxx --note "..."` |
97
+ | PM confirms scope / sends it back | `tspl task scope t-xxxx` / `tspl task scope t-xxxx --back --note "..."` |
98
+ | Keep going through approvals and CI | `tspl task watch t-xxxx` |
99
+ | Builds in progress | `tspl build status` |
100
+ | Stop a build and lift its edit lock | `tspl build stop t-xxxx` |
101
+ | Allow a new plan after a stop | `tspl plan amend t-xxxx` |
102
+ | Delete a task | `tspl task delete t-xxxx --reason "..."` |
103
+ | Who is logged in | `tspl whoami` |
104
+ | AI engine per step | `tspl engines` |
105
+
106
+ ## Team and projects
107
+
108
+ ```bash
109
+ tspl project list # projects you can see
110
+ tspl member list # people and their roles
111
+ tspl member add <email> # owner or admin
112
+ tspl member role <email> <role>
113
+ tspl project add-member <project> <email>
114
+ tspl project remove-member <project> <email>
115
+ ```
116
+
117
+ ## Where tspl keeps data
118
+
119
+ - `~/.tspl`: your login, settings and the local code map of each repo. The map is built from git and never uploaded.
120
+ - `.tspl/project.json` in your project: the project's checks and settings, meant to be committed.
121
+ - The TSPL server stores tasks, plans, approvals, reports and AI usage, never your code.
122
+
123
+ ## Help
124
+
125
+ ```bash
126
+ tspl --help
127
+ tspl <command> --help
128
+ ```
package/bin/tspl.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const problem = require('../src/runtime-check').checkRuntime()
5
+ if (problem) {
6
+ console.error(problem)
7
+ process.exit(1)
8
+ }
9
+
10
+ require('../src/index').run(process.argv)
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "tspl-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "TSPL command-line tool: learns your code, plans changes with you, builds, tests and opens pull requests",
5
+ "keywords": [
6
+ "tspl",
7
+ "cli",
8
+ "ai",
9
+ "mcp",
10
+ "claude",
11
+ "code-review",
12
+ "planning"
13
+ ],
14
+ "license": "UNLICENSED",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/meet2amit/tspl-pm-aitool.git",
18
+ "directory": "apps/cli"
19
+ },
20
+ "bin": {
21
+ "tspl": "bin/tspl.js"
22
+ },
23
+ "main": "src/index.js",
24
+ "files": [
25
+ "bin",
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "dependencies": {
33
+ "@babel/parser": "^7.29.9",
34
+ "@inquirer/prompts": "^8.7.2",
35
+ "@modelcontextprotocol/sdk": "^1.31.0",
36
+ "commander": "^15.0.0",
37
+ "zod": "^4.6.5"
38
+ },
39
+ "scripts": {},
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
@@ -0,0 +1,49 @@
1
+ 'use strict'
2
+
3
+ const Fs = require('fs')
4
+ const Path = require('path')
5
+
6
+ const { readLinks, accountFor, setActiveOrganization } = require('./config-store')
7
+ const { findProjectConfig } = require('./project-config')
8
+
9
+ const inside = (dir, parent) => Boolean(parent) && (dir === parent || dir.startsWith(`${parent}${Path.sep}`))
10
+
11
+ const real = dir => {
12
+ try {
13
+ return Fs.realpathSync(dir)
14
+ } catch (err) {
15
+ return Path.resolve(dir)
16
+ }
17
+ }
18
+
19
+ /**
20
+ * The organization a folder belongs to: the one named in its .tspl/project.json, else the one whose linked project
21
+ * contains the folder. null when the folder is not part of a project or the link is ambiguous.
22
+ */
23
+ const organizationOfFolder = cwd => {
24
+ try {
25
+ const found = findProjectConfig(cwd)
26
+ if (found) return found.config.organization
27
+ } catch (err) {
28
+ // a broken project file is reported by the command that needs it
29
+ }
30
+ const dir = real(cwd)
31
+ const orgs = new Set(
32
+ Object.entries(readLinks())
33
+ .filter(([, link]) => inside(dir, link.folder && real(link.folder)) || Object.values(link.repos || {}).some(repo => inside(dir, repo.path && real(repo.path))))
34
+ .map(([key]) => key.split('/')[0]),
35
+ )
36
+ return orgs.size === 1 ? [...orgs][0] : null
37
+ }
38
+
39
+ /** Makes every command in this folder use the login of the folder's organization (when this machine has one). */
40
+ const useFolderOrganization = cwd => {
41
+ const organization = organizationOfFolder(cwd)
42
+ if (organization && accountFor(organization)) setActiveOrganization(organization)
43
+ return organization
44
+ }
45
+
46
+ module.exports = {
47
+ organizationOfFolder,
48
+ useFolderOrganization,
49
+ }
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+
3
+ const { readIndex, writeIndex } = require('../config-store')
4
+ const { buildRepoIndex } = require('./repo-index')
5
+
6
+ const COUNTED = ['routes', 'models', 'uiRoutes', 'screens', 'components', 'httpCalls']
7
+
8
+ const refreshIndex = async (project, repos, onRepo = () => {}, saved = readIndex(project._id)) => {
9
+ const index = { projectId: project._id, repos: { ...(saved.repos || {}) } }
10
+ let changed = false
11
+ for (const repo of repos) {
12
+ try {
13
+ const repoIndex = await buildRepoIndex(repo, index.repos[repo._id])
14
+ const isNew = Boolean(repoIndex.newlyParsed || !index.repos[repo._id] || index.repos[repo._id].commitSha !== repoIndex.commitSha)
15
+ changed = changed || isNew
16
+ index.repos[repo._id] = repoIndex
17
+ onRepo(repo, repoIndex, null, isNew)
18
+ } catch (err) {
19
+ onRepo(repo, null, err)
20
+ }
21
+ }
22
+ const linked = new Set(repos.map(repo => repo._id))
23
+ Object.keys(index.repos)
24
+ .filter(id => !linked.has(id))
25
+ .forEach(id => {
26
+ delete index.repos[id]
27
+ changed = true
28
+ })
29
+ if (changed) {
30
+ writeIndex(project._id, index)
31
+ }
32
+ return index
33
+ }
34
+
35
+ const describeRepo = repoIndex => {
36
+ const stats = { byStatus: {} }
37
+ COUNTED.forEach(key => { stats[key] = 0 })
38
+ repoIndex.files.forEach(file => {
39
+ stats.byStatus[file.status] = (stats.byStatus[file.status] || 0) + 1
40
+ })
41
+ Object.values(repoIndex.facts).forEach(facts => {
42
+ if (!facts) return
43
+ COUNTED.forEach(key => {
44
+ if (Array.isArray(facts[key])) stats[key] += facts[key].length
45
+ })
46
+ })
47
+ const codeFiles = Object.keys(repoIndex.facts).length
48
+ return {
49
+ branch: repoIndex.branch,
50
+ commitSha: repoIndex.commitSha,
51
+ date: repoIndex.date,
52
+ codeFiles,
53
+ newlyAnalyzed: repoIndex.newlyParsed || 0,
54
+ stats,
55
+ }
56
+ }
57
+
58
+ module.exports = {
59
+ refreshIndex,
60
+ describeRepo,
61
+ }
@@ -0,0 +1,37 @@
1
+ 'use strict'
2
+
3
+ const Path = require('path')
4
+ const { extractJs } = require('./js-parser')
5
+ const { extractPy } = require('./py-parser')
6
+ const { extractHtml } = require('./html-parser')
7
+
8
+ const PARSER_VERSION = 3
9
+
10
+ const LANGUAGES = {
11
+ '.js': 'javascript',
12
+ '.jsx': 'javascript',
13
+ '.mjs': 'javascript',
14
+ '.cjs': 'javascript',
15
+ '.ts': 'typescript',
16
+ '.tsx': 'typescript',
17
+ '.py': 'python',
18
+ '.html': 'html',
19
+ }
20
+
21
+ const withoutEmpty = facts =>
22
+ Object.fromEntries(
23
+ Object.entries(facts).filter(([, value]) => !(Array.isArray(value) && !value.length)),
24
+ )
25
+
26
+ const extractFacts = (path, code) => {
27
+ const ext = Path.extname(path).toLowerCase()
28
+ const language = LANGUAGES[ext]
29
+ const parsers = { python: extractPy, html: extractHtml }
30
+ const facts = parsers[language] ? parsers[language](code) : extractJs(code, ext)
31
+ return withoutEmpty({ language, lines: code.split('\n').length, ...facts })
32
+ }
33
+
34
+ module.exports = {
35
+ PARSER_VERSION,
36
+ extractFacts,
37
+ }
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ const Path = require('path')
4
+
5
+ const MAX_BYTES = 512 * 1024
6
+
7
+ const CODE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.py', '.html'])
8
+
9
+ const SECRET_PATTERNS = [
10
+ /(^|\/)\.env(\.[^/]*)?$/i,
11
+ /\.(pem|key|p12|pfx|jks|keystore|p8|mobileprovision|cer|crt|der|asc|gpg)$/i,
12
+ /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
13
+ /(^|\/)\.(npmrc|pypirc|netrc)$/i,
14
+ /(^|\/)google-services\.json$/i,
15
+ /(^|\/)GoogleService-Info\.plist$/i,
16
+ /(^|\/)service-account[^/]*\.json$/i,
17
+ /(^|\/)\.(aws|ssh)\//i,
18
+ ]
19
+
20
+ const SECRET_NAME = /(^|[._-])(secrets?|credentials?)([._-]|$)/i
21
+
22
+ const GENERATED_PATTERNS = [
23
+ /(^|\/)(node_modules|bower_components|dist|build|coverage|vendor|\.next|\.expo|\.nuxt|Pods|\.gradle)\//,
24
+ /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json|Podfile\.lock)$/,
25
+ /\.(min\.js|min\.css|map|snap|lock)$/i,
26
+ ]
27
+
28
+ const BINARY_EXTENSIONS = new Set([
29
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tif', '.tiff', '.heic',
30
+ '.mp4', '.mov', '.avi', '.mp3', '.wav', '.ogg', '.m4a',
31
+ '.pdf', '.zip', '.gz', '.tgz', '.rar', '.7z', '.jar', '.war', '.tar',
32
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
33
+ '.psd', '.ai', '.sketch', '.fig', '.xls', '.xlsx', '.doc', '.docx', '.ppt', '.pptx',
34
+ '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite', '.apk', '.ipa', '.aab',
35
+ ])
36
+
37
+ const SECRET_VALUE_NAME = /(key|secret|token|passw|pwd|auth|credential|private|salt|signature|cert|bearer|session|cookie|dsn)/i
38
+ const SECRET_VALUE = /-----BEGIN|^(sk|pk|rk|ghp|gho|xox[abp])[-_]|^AKIA|^eyJ|:\/\/[^\s/]*:[^\s/]*@|(password|pwd|secret|token)=/i
39
+
40
+ const isSecretValue = (name, value) => {
41
+ const text = String(value)
42
+ if ((name && SECRET_VALUE_NAME.test(name)) || SECRET_VALUE.test(text)) return true
43
+ return !/\s/.test(text) && text.length >= 20 && /[a-z]/i.test(text) && /\d/.test(text)
44
+ }
45
+
46
+ const classify = (path, size) => {
47
+ if (
48
+ SECRET_PATTERNS.some(pattern => pattern.test(path)) ||
49
+ SECRET_NAME.test(Path.basename(path))
50
+ ) {
51
+ return 'skipped-secret'
52
+ }
53
+ if (GENERATED_PATTERNS.some(pattern => pattern.test(path))) {
54
+ return 'skipped-generated'
55
+ }
56
+ const ext = Path.extname(path).toLowerCase()
57
+ if (BINARY_EXTENSIONS.has(ext)) {
58
+ return 'skipped-binary'
59
+ }
60
+ if (size > MAX_BYTES) {
61
+ return 'skipped-large'
62
+ }
63
+ return CODE_EXTENSIONS.has(ext) ? 'parsed' : 'listed'
64
+ }
65
+
66
+ module.exports = {
67
+ classify,
68
+ isSecretValue,
69
+ }
@@ -0,0 +1,108 @@
1
+ 'use strict'
2
+
3
+ const { execFileSync, spawn } = require('child_process')
4
+
5
+ const git = (dir, args, options = {}) =>
6
+ execFileSync('git', ['-C', dir, ...args], {
7
+ encoding: options.encoding || 'utf8',
8
+ maxBuffer: 256 * 1024 * 1024,
9
+ stdio: ['ignore', 'pipe', 'ignore'],
10
+ })
11
+
12
+ const verify = (dir, ref) => {
13
+ try {
14
+ return git(dir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).trim()
15
+ } catch (err) {
16
+ return null
17
+ }
18
+ }
19
+
20
+ const resolveRef = (dir, branch) => {
21
+ const candidates = branch
22
+ ? [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]
23
+ : ['HEAD']
24
+ for (const ref of candidates) {
25
+ const commitSha = verify(dir, ref)
26
+ if (commitSha) {
27
+ const date = git(dir, ['log', '-1', '--format=%cs', commitSha]).trim()
28
+ return { ref, commitSha, date }
29
+ }
30
+ }
31
+ throw new Error(branch ? `branch "${branch}" not found` : 'no commits found')
32
+ }
33
+
34
+ const listFiles = (dir, ref) =>
35
+ git(dir, ['ls-tree', '-r', '-l', '-z', ref])
36
+ .split('\0')
37
+ .filter(Boolean)
38
+ .map(line => {
39
+ const tab = line.indexOf('\t')
40
+ const [, type, blobHash, size] = line.slice(0, tab).split(/\s+/)
41
+ return { type, blobHash, size: Number(size) || 0, path: line.slice(tab + 1) }
42
+ })
43
+ .filter(entry => entry.type === 'blob')
44
+ .map(({ blobHash, size, path }) => ({ blobHash, size, path }))
45
+
46
+ const HISTORY_COMMITS = 4000
47
+ const HISTORY_PER_FILE = 40
48
+ const HISTORY_MAX_FILES = 30
49
+
50
+ const fileHistory = (dir, ref, wanted) => {
51
+ const history = {}
52
+ const log = git(dir, ['-c', 'core.quotePath=false', 'log', ref, '--no-merges', '-n', String(HISTORY_COMMITS), '--name-only', '--format=%x00%s'])
53
+ log
54
+ .split('\0')
55
+ .filter(Boolean)
56
+ .forEach(block => {
57
+ const [message, ...paths] = block.split('\n').map(line => line.trim())
58
+ const files = paths.filter(path => path && wanted.has(path))
59
+ if (!message || /^(merge|squashed commit)/i.test(message) || paths.filter(Boolean).length > HISTORY_MAX_FILES) return
60
+ files.forEach(path => {
61
+ const messages = history[path] || (history[path] = [])
62
+ if (messages.length < HISTORY_PER_FILE && !messages.includes(message)) messages.push(message)
63
+ })
64
+ })
65
+ return history
66
+ }
67
+
68
+ const readBlobs = (dir, hashes) =>
69
+ new Promise((resolve, reject) => {
70
+ const child = spawn('git', ['-C', dir, 'cat-file', '--batch'], {
71
+ stdio: ['pipe', 'pipe', 'ignore'],
72
+ })
73
+ const chunks = []
74
+ child.stdout.on('data', chunk => chunks.push(chunk))
75
+ child.on('error', reject)
76
+ child.on('close', code => {
77
+ if (code !== 0) {
78
+ reject(new Error(`git cat-file failed in ${dir}`))
79
+ return
80
+ }
81
+ const buffer = Buffer.concat(chunks)
82
+ const blobs = new Map()
83
+ let offset = 0
84
+ while (offset < buffer.length) {
85
+ const newline = buffer.indexOf(0x0a, offset)
86
+ if (newline === -1) {
87
+ break
88
+ }
89
+ const [hash, type, size] = buffer.toString('utf8', offset, newline).split(' ')
90
+ offset = newline + 1
91
+ if (type === 'missing') {
92
+ continue
93
+ }
94
+ const length = Number(size)
95
+ blobs.set(hash, buffer.toString('utf8', offset, offset + length))
96
+ offset += length + 1
97
+ }
98
+ resolve(blobs)
99
+ })
100
+ child.stdin.end(`${hashes.join('\n')}\n`)
101
+ })
102
+
103
+ module.exports = {
104
+ resolveRef,
105
+ listFiles,
106
+ fileHistory,
107
+ readBlobs,
108
+ }
@@ -0,0 +1,46 @@
1
+ 'use strict'
2
+
3
+ const LIMIT = 500
4
+ const LABEL_LIMIT = 150
5
+
6
+ const CUSTOM_TAG = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]/g
7
+ const LABEL_ATTRIBUTE = /\s(?:title|placeholder|label|alt|aria-label|matTooltip|ngbTooltip)\s*=\s*"([^"{}]+)"/g
8
+ const ROUTER_LINK = /\srouterLink\s*=\s*"(\/[^"{}]*)"/g
9
+
10
+ const labelText = value => {
11
+ const label = value.replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
12
+ return label.length >= 2 && label.length <= 80 && /[a-z]/i.test(label) ? label : null
13
+ }
14
+
15
+ const extractHtml = code => {
16
+ const tags = new Set()
17
+ const labels = new Set()
18
+ const links = new Set()
19
+ const markup = code
20
+ .replace(/<!--[\s\S]*?-->/g, ' ')
21
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
22
+
23
+ for (const match of markup.matchAll(CUSTOM_TAG)) tags.add(match[1])
24
+ for (const match of markup.matchAll(ROUTER_LINK)) links.add(match[1])
25
+ for (const match of markup.matchAll(LABEL_ATTRIBUTE)) {
26
+ const label = labelText(match[1])
27
+ if (label) labels.add(label)
28
+ }
29
+ markup
30
+ .replace(/\{\{[\s\S]*?\}\}/g, ' ')
31
+ .split(/<[^>]*>/)
32
+ .forEach(chunk => {
33
+ const label = labelText(chunk)
34
+ if (label) labels.add(label)
35
+ })
36
+
37
+ return {
38
+ tags: [...tags].slice(0, LIMIT),
39
+ links: [...links].slice(0, LIMIT),
40
+ labels: [...labels].slice(0, LABEL_LIMIT),
41
+ }
42
+ }
43
+
44
+ module.exports = {
45
+ extractHtml,
46
+ }