agentplugged 1.2.0 → 1.4.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/README.md +3 -2
- package/dist/agent-commands.js +25689 -24738
- package/dist/bin.js +1 -2
- package/dist/cli.js +1 -2
- package/dist/dist.js +12205 -0
- package/dist/esm.js +8 -16
- package/dist/forge-follow.js +5 -6
- package/dist/hash.js +9 -11907
- package/dist/helpers.js +257 -514
- package/dist/launcher-env.js +1 -2
- package/dist/launcher.js +3 -4
- package/dist/libsodium-wrappers.js +2 -4
- package/dist/libsodium-wrappers2.js +2 -4
- package/dist/main.js +820 -166
- package/dist/method/agents/alex/SKILL.md +13 -29
- package/dist/method/agents/alex/agent.yaml +1 -1
- package/dist/method/agents/basile/SKILL.md +44 -0
- package/dist/method/agents/basile/agent.yaml +69 -0
- package/dist/method/agents/chloe/SKILL.md +12 -25
- package/dist/method/agents/chloe/agent.yaml +3 -3
- package/dist/method/agents/ethan/SKILL.md +8 -9
- package/dist/method/agents/ethan/agent.yaml +9 -8
- package/dist/method/agents/hugo/SKILL.md +8 -8
- package/dist/method/agents/hugo/agent.yaml +9 -8
- package/dist/method/agents/iris/SKILL.md +4 -10
- package/dist/method/agents/izzy/SKILL.md +68 -91
- package/dist/method/agents/izzy/agent.yaml +8 -15
- package/dist/method/agents/jade/SKILL.md +7 -9
- package/dist/method/agents/jade/agent.yaml +9 -8
- package/dist/method/agents/julien/SKILL.md +38 -58
- package/dist/method/agents/lina/SKILL.md +7 -9
- package/dist/method/agents/lina/agent.yaml +9 -8
- package/dist/method/agents/lou/SKILL.md +4 -11
- package/dist/method/agents/lou/agent.yaml +2 -2
- package/dist/method/agents/maya/SKILL.md +12 -19
- package/dist/method/agents/maya/agent.yaml +1 -3
- package/dist/method/agents/milo/SKILL.md +21 -32
- package/dist/method/agents/noah/SKILL.md +109 -61
- package/dist/method/agents/noah/agent.yaml +14 -31
- package/dist/method/agents/noah/references/decisions.md +61 -120
- package/dist/method/agents/noah/references/environment.md +30 -70
- package/dist/method/agents/noah/references/facts.md +22 -11
- package/dist/method/agents/noah/references/journey.md +26 -78
- package/dist/method/agents/noah/references/team.md +63 -190
- package/dist/method/agents/omar/SKILL.md +12 -28
- package/dist/method/agents/omar/agent.yaml +1 -1
- package/dist/method/agents/quentin/SKILL.md +7 -15
- package/dist/method/agents/quentin/agent.yaml +4 -2
- package/dist/method/agents/sam/SKILL.md +6 -8
- package/dist/method/agents/sam/agent.yaml +9 -8
- package/dist/method/agents/stella/SKILL.md +6 -14
- package/dist/method/agents/stella/agent.yaml +1 -1
- package/dist/method/agents/tess/SKILL.md +7 -14
- package/dist/method/agents/tess/agent.yaml +2 -2
- package/dist/method/agents/zoe/SKILL.md +9 -21
- package/dist/method/doctor.yaml +23 -4
- package/dist/method/models.yaml +12 -0
- package/dist/method/skills/acceptance-qa/SKILL.md +1 -1
- package/dist/method/skills/acceptance-qa/references/run.md +24 -32
- package/dist/method/skills/acceptance-qa/references/scenarios.md +16 -19
- package/dist/method/skills/acceptance-qa/references/verdicts.md +10 -16
- package/dist/method/skills/api-e2e-tests/SKILL.md +46 -46
- package/dist/method/skills/audit-pillars/SKILL.md +12 -10
- package/dist/method/skills/audit-pillars/references/pillars.md +40 -0
- package/dist/method/skills/chain-stories/SKILL.md +5 -4
- package/dist/method/skills/chain-stories/references/merge-order.md +23 -0
- package/dist/method/skills/check-readiness/SKILL.md +1 -1
- package/dist/method/skills/check-requirements/SKILL.md +37 -72
- package/dist/method/skills/check-requirements/references/engine-proofs.md +16 -23
- package/dist/method/skills/check-requirements/references/proofs-and-gaps.md +22 -37
- package/dist/method/skills/critique-deliverable/SKILL.md +3 -3
- package/dist/method/skills/critique-deliverable/references/choose.md +12 -21
- package/dist/method/skills/critique-deliverable/references/format.md +14 -20
- package/dist/method/skills/critique-deliverable/references/gestures.md +14 -21
- package/dist/method/skills/critique-deliverable/references/judgment.md +4 -9
- package/dist/method/skills/critique-deliverable/references/methods.md +21 -0
- package/dist/method/skills/critique-deliverable/references/reconcile.md +6 -11
- package/dist/method/skills/deep-research/SKILL.md +16 -24
- package/dist/method/skills/deep-research/references/candidates.md +13 -25
- package/dist/method/skills/deep-research/references/external.md +15 -34
- package/dist/method/skills/deep-research/references/page-format.md +16 -42
- package/dist/method/skills/deep-research/references/types.md +16 -27
- package/dist/method/skills/develop-story/SKILL.md +56 -189
- package/dist/method/skills/develop-story/references/acceptance-tests.md +18 -71
- package/dist/method/skills/develop-story/references/quick-lane.md +20 -0
- package/dist/method/skills/diagnose-bug/SKILL.md +42 -53
- package/dist/method/skills/diagnose-bug/references/hypotheses.md +1 -2
- package/dist/method/skills/generate-knowledge-base/SKILL.md +57 -226
- package/dist/method/skills/generate-knowledge-base/assets/index-template.md +6 -15
- package/dist/method/skills/generate-knowledge-base/assets/page-template.md +11 -24
- package/dist/method/skills/generate-knowledge-base/references/living-docs.md +25 -64
- package/dist/method/skills/generate-knowledge-base/references/reuse-map.md +9 -18
- package/dist/method/skills/ideation/SKILL.md +16 -23
- package/dist/method/skills/ideation/references/close.md +18 -14
- package/dist/method/skills/ideation/references/converge.md +11 -21
- package/dist/method/skills/ideation/references/stances.md +13 -17
- package/dist/method/skills/ideation/references/techniques.md +20 -0
- package/dist/method/skills/improve-code/SKILL.md +42 -54
- package/dist/method/skills/pitch/SKILL.md +16 -19
- package/dist/method/skills/pitch/references/deck.md +15 -18
- package/dist/method/skills/pitch/references/design-implementation.md +13 -21
- package/dist/method/skills/pitch/references/design.md +21 -0
- package/dist/method/skills/pitch/references/story.md +13 -18
- package/dist/method/skills/plan-sprint/SKILL.md +49 -151
- package/dist/method/skills/resolve-merge-conflict/SKILL.md +40 -65
- package/dist/method/skills/review-after-launch/SKILL.md +56 -0
- package/dist/method/skills/review-story/SKILL.md +44 -72
- package/dist/method/skills/review-story/references/amend-story.md +8 -13
- package/dist/method/skills/review-story/references/export.md +7 -17
- package/dist/method/skills/review-story/references/free-review.md +9 -20
- package/dist/method/skills/review-story/references/judge.md +25 -37
- package/dist/method/skills/review-story/references/tests.md +29 -0
- package/dist/method/skills/round-table/SKILL.md +6 -6
- package/dist/method/skills/round-table/references/closing.md +18 -0
- package/dist/method/skills/round-table/references/guests.md +15 -15
- package/dist/method/skills/round-table/references/rooms.md +15 -28
- package/dist/method/skills/round-table/references/table.md +19 -0
- package/dist/method/skills/run-acceptance/SKILL.md +58 -0
- package/dist/method/skills/run-acceptance/references/template.md +39 -0
- package/dist/method/skills/run-retrospective/SKILL.md +50 -189
- package/dist/method/skills/security-review-architecture/SKILL.md +47 -109
- package/dist/method/skills/security-review-architecture/references/spec-review.md +27 -38
- package/dist/method/skills/setup-ci/SKILL.md +45 -109
- package/dist/method/skills/split-into-stories/SKILL.md +59 -192
- package/dist/method/skills/split-into-stories/assets/story.md.tmpl +15 -4
- package/dist/method/skills/strategy/SKILL.md +21 -25
- package/dist/method/skills/strategy/references/costs.md +12 -26
- package/dist/method/skills/strategy/references/gestures.md +12 -21
- package/dist/method/skills/strategy/references/innovate.md +19 -37
- package/dist/method/skills/strategy/references/solve.md +19 -32
- package/dist/method/skills/test-idea/SKILL.md +5 -5
- package/dist/method/skills/test-idea/assets/prfaq-template.md +1 -1
- package/dist/method/skills/test-idea/references/faq.md +12 -29
- package/dist/method/skills/test-idea/references/forge.md +19 -30
- package/dist/method/skills/test-idea/references/personas.md +16 -19
- package/dist/method/skills/test-idea/references/prfaq.md +31 -31
- package/dist/method/skills/workshop/SKILL.md +10 -16
- package/dist/method/skills/workshop/references/create.md +28 -45
- package/dist/method/skills/workshop/references/customize.md +17 -42
- package/dist/method/skills/workshop/references/evaluate.md +11 -42
- package/dist/method/skills/workshop/references/rules.md +17 -26
- package/dist/method/skills/write-architecture/SKILL.md +62 -220
- package/dist/method/skills/write-architecture/references/guard.md +11 -28
- package/dist/method/skills/write-architecture/references/self-check.md +22 -38
- package/dist/method/skills/write-brand/SKILL.md +2 -2
- package/dist/method/skills/write-brand/references/identity.md +21 -22
- package/dist/method/skills/write-brand/references/platform.md +13 -20
- package/dist/method/skills/write-brand/references/rules.md +16 -22
- package/dist/method/skills/write-brief/SKILL.md +56 -149
- package/dist/method/skills/write-brief/references/brief-checklist.md +18 -30
- package/dist/method/skills/write-content/SKILL.md +53 -210
- package/dist/method/skills/write-content/references/content-checklist.md +32 -50
- package/dist/method/skills/write-positioning/SKILL.md +53 -197
- package/dist/method/skills/write-positioning/references/positioning-checklist.md +15 -41
- package/dist/method/skills/write-positioning/references/seo-checklist.md +26 -51
- package/dist/method/skills/write-prd/SKILL.md +52 -179
- package/dist/method/skills/write-prd/references/prd-checklist.md +25 -54
- package/dist/method/skills/write-sales/SKILL.md +48 -212
- package/dist/method/skills/write-sales/references/before-you-send.md +21 -49
- package/dist/method/skills/write-sales/references/sales-checklist.md +19 -69
- package/dist/method/skills/write-spec/references/spec-guide.md +20 -23
- package/dist/method/skills/write-ux-spec/SKILL.md +57 -168
- package/dist/method/skills/write-ux-spec/references/accessibility-check.md +13 -72
- package/dist/method/skills/write-ux-spec/references/mockups.md +6 -68
- package/dist/method/skills/write-ux-spec/references/tokens-and-sketches.md +8 -81
- package/dist/method/skills/write-ux-spec/references/ux-checklist.md +12 -67
- package/dist/product-root.js +15747 -15091
- package/dist/project-command.js +1508 -0
- package/dist/project-command2.js +1 -0
- package/dist/read-command.js +19 -0
- package/dist/rolldown-runtime.js +1 -2
- package/dist/sheet-command.js +57 -0
- package/dist/webauthn-verify.js +4 -5
- package/package.json +11 -10
- package/dist/method/agents/noah/references/conduct.md +0 -99
- package/dist/method/skills/api-e2e-tests/references/frameworks.md +0 -58
- package/dist/method/skills/api-e2e-tests/references/generate.md +0 -67
- package/dist/method/skills/audit-pillars/references/accessibility.md +0 -13
- package/dist/method/skills/audit-pillars/references/architecture.md +0 -13
- package/dist/method/skills/audit-pillars/references/dependencies.md +0 -13
- package/dist/method/skills/audit-pillars/references/format.md +0 -20
- package/dist/method/skills/audit-pillars/references/interface.md +0 -13
- package/dist/method/skills/audit-pillars/references/method.md +0 -33
- package/dist/method/skills/audit-pillars/references/performance.md +0 -17
- package/dist/method/skills/audit-pillars/references/quality.md +0 -11
- package/dist/method/skills/audit-pillars/references/security.md +0 -13
- package/dist/method/skills/audit-pillars/references/tests.md +0 -17
- package/dist/method/skills/critique-deliverable/references/assumption-audit.md +0 -11
- package/dist/method/skills/critique-deliverable/references/compare-existing.md +0 -13
- package/dist/method/skills/critique-deliverable/references/devils-advocate.md +0 -13
- package/dist/method/skills/critique-deliverable/references/divergence.md +0 -15
- package/dist/method/skills/critique-deliverable/references/domain-expert.md +0 -14
- package/dist/method/skills/critique-deliverable/references/extreme-user.md +0 -13
- package/dist/method/skills/critique-deliverable/references/failure-modes.md +0 -13
- package/dist/method/skills/critique-deliverable/references/first-principles.md +0 -12
- package/dist/method/skills/critique-deliverable/references/inversion.md +0 -13
- package/dist/method/skills/critique-deliverable/references/pre-mortem.md +0 -12
- package/dist/method/skills/critique-deliverable/references/red-team.md +0 -12
- package/dist/method/skills/critique-deliverable/references/reframe.md +0 -12
- package/dist/method/skills/critique-deliverable/references/second-order.md +0 -12
- package/dist/method/skills/critique-deliverable/references/socratic.md +0 -15
- package/dist/method/skills/critique-deliverable/references/steelman.md +0 -13
- package/dist/method/skills/critique-deliverable/references/subtraction.md +0 -12
- package/dist/method/skills/develop-story/references/self-check.md +0 -46
- package/dist/method/skills/generate-knowledge-base/references/kb-checklist.md +0 -49
- package/dist/method/skills/generate-knowledge-base/references/repository-scan.md +0 -48
- package/dist/method/skills/ideation/references/choose.md +0 -21
- package/dist/method/skills/ideation/references/gestures.md +0 -15
- package/dist/method/skills/ideation/references/techniques-borrowed.md +0 -8
- package/dist/method/skills/ideation/references/techniques-deep.md +0 -12
- package/dist/method/skills/ideation/references/techniques-frames.md +0 -12
- package/dist/method/skills/ideation/references/techniques-futures.md +0 -8
- package/dist/method/skills/ideation/references/techniques-limits.md +0 -9
- package/dist/method/skills/ideation/references/techniques-people.md +0 -7
- package/dist/method/skills/ideation/references/techniques-sparks.md +0 -10
- package/dist/method/skills/ideation/references/techniques-together.md +0 -11
- package/dist/method/skills/order-merges/SKILL.md +0 -71
- package/dist/method/skills/pitch/references/deck-types.md +0 -17
- package/dist/method/skills/pitch/references/design-methods.md +0 -45
- package/dist/method/skills/pitch/references/design-thinking.md +0 -28
- package/dist/method/skills/pitch/references/story-frameworks.md +0 -39
- package/dist/method/skills/quick-lane/SKILL.md +0 -143
- package/dist/method/skills/review-tests/SKILL.md +0 -32
- package/dist/method/skills/review-tests/references/check.md +0 -25
- package/dist/method/skills/review-tests/references/grid.md +0 -27
- package/dist/method/skills/round-table/references/cast.md +0 -14
- package/dist/method/skills/round-table/references/gestures.md +0 -24
- package/dist/method/skills/round-table/references/independence.md +0 -21
- package/dist/method/skills/round-table/references/three-views.md +0 -17
- package/dist/method/skills/round-table/references/turns.md +0 -19
- package/dist/method/skills/strategy/references/innovate-frameworks.md +0 -48
- package/dist/method/skills/strategy/references/solve-methods.md +0 -45
- package/dist/method/skills/test-idea/references/bets.md +0 -14
- package/dist/method/skills/test-idea/references/concept.md +0 -24
- package/dist/method/skills/test-idea/references/verdict.md +0 -26
- package/dist/method/skills/workshop/references/analyze.md +0 -36
|
@@ -0,0 +1,1508 @@
|
|
|
1
|
+
import { Fo as issueName, ts as ErrorCode } from "./dist.js";
|
|
2
|
+
// ../../packages/core/dist/init/layout.js
|
|
3
|
+
/** Folder holding the method inside the product's repository. */
|
|
4
|
+
const METHOD_DIR = ".agentplugged";
|
|
5
|
+
const METHOD_CONFIG_PATH = `${METHOD_DIR}/method.yaml`;
|
|
6
|
+
const METHOD_GITIGNORE_PATH = `${METHOD_DIR}/.gitignore`;
|
|
7
|
+
/** Copy of the shipped model table, generated by `init`; the creator's values go in `method.yaml`. */
|
|
8
|
+
const MODELS_PATH = `${METHOD_DIR}/models.yaml`;
|
|
9
|
+
const METHOD_CONSTITUTION_PATH = `${METHOD_DIR}/AGENTS.md`;
|
|
10
|
+
const ROOT_CONSTITUTION_PATH = "AGENTS.md";
|
|
11
|
+
/**
|
|
12
|
+
* The detail of the constitution, generated, read on demand (corrections B; one file
|
|
13
|
+
* per section): the `AGENTS.md` that the tools load at every request stays short.
|
|
14
|
+
*/
|
|
15
|
+
const REFERENCE_DIR = `${METHOD_DIR}/reference`;
|
|
16
|
+
/** The single file the reference was: `sync` removes it when it generated it. */
|
|
17
|
+
const REFERENCE_LEGACY_PATH = `${METHOD_DIR}/REFERENCE.md`;
|
|
18
|
+
/** The file of one section of the reference, by its short name (`reporting`, `tool-story`…). */
|
|
19
|
+
const referenceFilePath = (name) => `${REFERENCE_DIR}/${name}.md`;
|
|
20
|
+
/** Sub-folders of `.agentplugged/`, each kept in Git by a `.gitkeep`. */
|
|
21
|
+
const METHOD_SUBDIRS = [
|
|
22
|
+
"agents",
|
|
23
|
+
"skills",
|
|
24
|
+
"docs",
|
|
25
|
+
"epics",
|
|
26
|
+
"stories",
|
|
27
|
+
"salle",
|
|
28
|
+
"memory",
|
|
29
|
+
"overrides",
|
|
30
|
+
".cache"
|
|
31
|
+
];
|
|
32
|
+
/** Tool instruction files that may belong to the creator; `init` never creates them. */
|
|
33
|
+
const POINTER_FILES = ["CLAUDE.md", "GEMINI.md"];
|
|
34
|
+
// ../../packages/core/dist/errors.js
|
|
35
|
+
/** Expected, translatable failure: `{ code, message_key, details }`. */
|
|
36
|
+
var AgentPluggedError = class extends Error {
|
|
37
|
+
code;
|
|
38
|
+
message_key;
|
|
39
|
+
details;
|
|
40
|
+
/**
|
|
41
|
+
* What the engine adds when it answers an agent (the gesture to make, how many times the same
|
|
42
|
+
* refusal came): sent with the details, never part of them, so that the cause of the refusal and
|
|
43
|
+
* the advice stay apart.
|
|
44
|
+
*/
|
|
45
|
+
advice;
|
|
46
|
+
constructor(code, messageKey, details = {}, advice = {}) {
|
|
47
|
+
super(`${code}: ${messageKey}`);
|
|
48
|
+
this.name = "AgentPluggedError";
|
|
49
|
+
this.code = code;
|
|
50
|
+
this.message_key = messageKey;
|
|
51
|
+
this.details = details;
|
|
52
|
+
this.advice = advice;
|
|
53
|
+
}
|
|
54
|
+
toPayload() {
|
|
55
|
+
return {
|
|
56
|
+
code: this.code,
|
|
57
|
+
message_key: this.message_key,
|
|
58
|
+
details: {
|
|
59
|
+
...this.details,
|
|
60
|
+
...this.advice
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* `details` of a field error: the English `reason`, plus `reason_key` (a message key surfaces
|
|
67
|
+
* translate) when the reason is a known schema message, or when `reasonKey` is given.
|
|
68
|
+
*/
|
|
69
|
+
function reasonDetails(reason, reasonKey, params = {}) {
|
|
70
|
+
const known = issueName(reason);
|
|
71
|
+
const key = reasonKey ?? (known === void 0 ? void 0 : `reason.${known}`);
|
|
72
|
+
return key === void 0 ? {
|
|
73
|
+
reason,
|
|
74
|
+
...params
|
|
75
|
+
} : {
|
|
76
|
+
reason,
|
|
77
|
+
reason_key: key,
|
|
78
|
+
...params
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** `UNEXPECTED`, with the reason it states. */
|
|
82
|
+
function unexpected(reason) {
|
|
83
|
+
return new AgentPluggedError(ErrorCode.UNEXPECTED, "error.unexpected", { reason });
|
|
84
|
+
}
|
|
85
|
+
/** `UNEXPECTED`: `what` (a port the caller did not give) is not available here. */
|
|
86
|
+
function unavailable(what) {
|
|
87
|
+
return unexpected(`${what} is not available here`);
|
|
88
|
+
}
|
|
89
|
+
// ../../packages/core/dist/reuse/tokens.js
|
|
90
|
+
/**
|
|
91
|
+
* The words of a source file, for any language. No parser: a scanner
|
|
92
|
+
* by regular expressions that every C-like or scripting language fits. Comments and blanks are
|
|
93
|
+
* dropped; identifiers become one word, numbers one word, strings one word, and the keywords (the
|
|
94
|
+
* union of the common languages') are kept: two functions that only differ by their names and
|
|
95
|
+
* values read the same. Statements made only of references to other files (imports) are dropped:
|
|
96
|
+
* many files import the same things, which is not a duplicate. Pure: text in, words out.
|
|
97
|
+
*/
|
|
98
|
+
/** The interned words of a run: one number per distinct normalised word. */
|
|
99
|
+
var WordTable = class {
|
|
100
|
+
#ids = /* @__PURE__ */ new Map();
|
|
101
|
+
intern(word) {
|
|
102
|
+
const known = this.#ids.get(word);
|
|
103
|
+
if (known !== void 0) return known;
|
|
104
|
+
const id = this.#ids.size + 1;
|
|
105
|
+
this.#ids.set(word, id);
|
|
106
|
+
return id;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const HASH = /* @__PURE__ */ new Set([
|
|
110
|
+
"py",
|
|
111
|
+
"rb",
|
|
112
|
+
"sh",
|
|
113
|
+
"bash",
|
|
114
|
+
"zsh",
|
|
115
|
+
"ps1",
|
|
116
|
+
"pl",
|
|
117
|
+
"pm",
|
|
118
|
+
"r",
|
|
119
|
+
"ex",
|
|
120
|
+
"exs",
|
|
121
|
+
"nim",
|
|
122
|
+
"cr",
|
|
123
|
+
"yml",
|
|
124
|
+
"yaml",
|
|
125
|
+
"toml"
|
|
126
|
+
]);
|
|
127
|
+
const DASH = /* @__PURE__ */ new Set([
|
|
128
|
+
"sql",
|
|
129
|
+
"lua",
|
|
130
|
+
"hs",
|
|
131
|
+
"elm",
|
|
132
|
+
"ada"
|
|
133
|
+
]);
|
|
134
|
+
const SLASH_HASH = /* @__PURE__ */ new Set([
|
|
135
|
+
"php",
|
|
136
|
+
"pl",
|
|
137
|
+
"pm"
|
|
138
|
+
]);
|
|
139
|
+
function styleOf(path) {
|
|
140
|
+
const extension = path.toLowerCase().split(".").at(-1) ?? "";
|
|
141
|
+
if (SLASH_HASH.has(extension)) return "slashhash";
|
|
142
|
+
if (HASH.has(extension)) return "hash";
|
|
143
|
+
return DASH.has(extension) ? "dash" : "slash";
|
|
144
|
+
}
|
|
145
|
+
/** The words of a text, as a set: lists of names written once, space-separated. */
|
|
146
|
+
function wordSet(text) {
|
|
147
|
+
return new Set(text.split(/\s+/).filter((word) => word !== ""));
|
|
148
|
+
}
|
|
149
|
+
const KEYWORDS = wordSet(`if else elif elsif unless for foreach while do until loop switch case default match when where
|
|
150
|
+
break continue return yield await async function def fn func fun class struct enum interface trait
|
|
151
|
+
impl type typedef let const var val mut static final abstract public private protected internal
|
|
152
|
+
override virtual readonly try catch except finally rescue ensure throw throws raise new delete
|
|
153
|
+
null nil none true false undefined this self super in of is as and or not with lambda pass defer
|
|
154
|
+
go chan select range import from export package use using module namespace extends implements
|
|
155
|
+
instanceof typeof void goto then begin end`);
|
|
156
|
+
/** Words that open a statement made only of references to other files. */
|
|
157
|
+
const IMPORTS = /* @__PURE__ */ new Set([
|
|
158
|
+
"import",
|
|
159
|
+
"from",
|
|
160
|
+
"using",
|
|
161
|
+
"package",
|
|
162
|
+
"use",
|
|
163
|
+
"require",
|
|
164
|
+
"require_relative"
|
|
165
|
+
]);
|
|
166
|
+
const PREPROCESSOR = /* @__PURE__ */ new Set([
|
|
167
|
+
"include",
|
|
168
|
+
"pragma",
|
|
169
|
+
"import",
|
|
170
|
+
"ifndef",
|
|
171
|
+
"ifdef",
|
|
172
|
+
"endif"
|
|
173
|
+
]);
|
|
174
|
+
const OPERATORS = ">>>=|<<=|>>=|\\*\\*=|\\.\\.\\.|===|!==|=>|->|::|&&|\\|\\||\\?\\?|\\?\\.|==|!=|<=|>=|\\+\\+|--|\\+=|-=|\\*=|/=|%=|&=|\\|=|\\^=|<<|>>|\\*\\*|\\.\\.";
|
|
175
|
+
function scannerOf(style) {
|
|
176
|
+
return [
|
|
177
|
+
"(\\s+)",
|
|
178
|
+
`(${style === "slash" ? "//[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|$)" : style === "slashhash" ? "//[^\\n]*|#[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|$)" : style === "hash" ? "(?<![${])#[^\\n]*" : "--\\[\\[[\\s\\S]*?(?:\\]\\]|$)|--[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|$)"})`,
|
|
179
|
+
"(\"\"\"[\\s\\S]*?(?:\"\"\"|$)|'''[\\s\\S]*?(?:'''|$))",
|
|
180
|
+
"(\"(?:\\\\.|[^\"\\\\\\n])*\"?|'(?:\\\\.|[^'\\\\\\n])*'?|`(?:\\\\[\\s\\S]|[^`\\\\])*`?)",
|
|
181
|
+
"([\\p{L}_$][\\p{L}\\p{N}_$]*)",
|
|
182
|
+
"(\\d[\\w.]*)",
|
|
183
|
+
`(${OPERATORS}|[\\s\\S])`
|
|
184
|
+
].join("|");
|
|
185
|
+
}
|
|
186
|
+
const SCANNERS = {
|
|
187
|
+
slash: scannerOf("slash"),
|
|
188
|
+
slashhash: scannerOf("slashhash"),
|
|
189
|
+
hash: scannerOf("hash"),
|
|
190
|
+
dash: scannerOf("dash")
|
|
191
|
+
};
|
|
192
|
+
function countLines(chunk) {
|
|
193
|
+
let count = 0;
|
|
194
|
+
for (let i = chunk.indexOf("\n"); i !== -1; i = chunk.indexOf("\n", i + 1)) count += 1;
|
|
195
|
+
return count;
|
|
196
|
+
}
|
|
197
|
+
function scan(text, style) {
|
|
198
|
+
const scanner = new RegExp(SCANNERS[style], "uy");
|
|
199
|
+
const raws = [];
|
|
200
|
+
let line = 1;
|
|
201
|
+
let first = true;
|
|
202
|
+
let position = 0;
|
|
203
|
+
while (position < text.length) {
|
|
204
|
+
scanner.lastIndex = position;
|
|
205
|
+
const match = scanner.exec(text);
|
|
206
|
+
if (match === null || match[0] === "") {
|
|
207
|
+
position += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const from = position;
|
|
211
|
+
position = scanner.lastIndex;
|
|
212
|
+
const [chunk, blank, comment, , , word, number, mark] = match;
|
|
213
|
+
const startLine = line;
|
|
214
|
+
line += countLines(chunk);
|
|
215
|
+
if (blank !== void 0) {
|
|
216
|
+
if (countLines(blank) > 0) first = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (comment !== void 0) continue;
|
|
220
|
+
const kind = word !== void 0 ? "word" : mark !== void 0 ? "mark" : number !== void 0 ? "number" : "string";
|
|
221
|
+
raws.push({
|
|
222
|
+
kind,
|
|
223
|
+
text: word ?? mark ?? "",
|
|
224
|
+
line: startLine,
|
|
225
|
+
first,
|
|
226
|
+
start: from,
|
|
227
|
+
end: position
|
|
228
|
+
});
|
|
229
|
+
first = false;
|
|
230
|
+
}
|
|
231
|
+
return raws;
|
|
232
|
+
}
|
|
233
|
+
const OPENING = /* @__PURE__ */ new Set([
|
|
234
|
+
"(",
|
|
235
|
+
"[",
|
|
236
|
+
"{"
|
|
237
|
+
]);
|
|
238
|
+
const CLOSING = /* @__PURE__ */ new Set([
|
|
239
|
+
")",
|
|
240
|
+
"]",
|
|
241
|
+
"}"
|
|
242
|
+
]);
|
|
243
|
+
/**
|
|
244
|
+
* The indexes of the raw words that belong to a statement of references (`import x from "y"`,
|
|
245
|
+
* `use a::{b, c};`, `#include <x>`, `from a import (b,\n c)`): from its first word to the end of its
|
|
246
|
+
* line, or of its brackets.
|
|
247
|
+
*/
|
|
248
|
+
function referenceWords(raws) {
|
|
249
|
+
const skipped = /* @__PURE__ */ new Set();
|
|
250
|
+
for (let i = 0; i < raws.length; i += 1) {
|
|
251
|
+
const raw = raws[i];
|
|
252
|
+
if (!raw.first || skipped.has(i)) continue;
|
|
253
|
+
const next = raws[i + 1];
|
|
254
|
+
let reference = false;
|
|
255
|
+
if (raw.kind === "word" && IMPORTS.has(raw.text)) reference = !(raw.text === "using" && next?.text === "(");
|
|
256
|
+
else if (raw.kind === "word" && raw.text === "export") reference = next?.text === "*" || next?.text === "{";
|
|
257
|
+
else if (raw.kind === "mark" && raw.text === "#" && next?.kind === "word") reference = PREPROCESSOR.has(next.text);
|
|
258
|
+
if (!reference) continue;
|
|
259
|
+
let depth = 0;
|
|
260
|
+
let j = i;
|
|
261
|
+
for (; j < raws.length; j += 1) {
|
|
262
|
+
const current = raws[j];
|
|
263
|
+
if (j > i && current.line !== raws[j - 1].line && depth <= 0) break;
|
|
264
|
+
skipped.add(j);
|
|
265
|
+
if (OPENING.has(current.text)) depth += 1;
|
|
266
|
+
else if (CLOSING.has(current.text)) depth -= 1;
|
|
267
|
+
else if (current.text === ";" && depth <= 0) {
|
|
268
|
+
j += 1;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
i = j - 1;
|
|
273
|
+
}
|
|
274
|
+
return skipped;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The words of a file as they stand, comments left out, with their lines and offsets: what the
|
|
278
|
+
* mutations need to change a comparison, a constant or a return without touching a
|
|
279
|
+
* string or a comment. `path` tells the comment style.
|
|
280
|
+
*/
|
|
281
|
+
function sourceWords(path, text) {
|
|
282
|
+
return scan(text, styleOf(path));
|
|
283
|
+
}
|
|
284
|
+
/** The normalised words of a file, with their lines. `path` tells the comment style. */
|
|
285
|
+
function wordsOf(path, text, table) {
|
|
286
|
+
const raws = scan(text, styleOf(path));
|
|
287
|
+
const skipped = referenceWords(raws);
|
|
288
|
+
const ids = [];
|
|
289
|
+
const lines = [];
|
|
290
|
+
raws.forEach((raw, index) => {
|
|
291
|
+
if (skipped.has(index)) return;
|
|
292
|
+
let word;
|
|
293
|
+
if (raw.kind === "word") word = KEYWORDS.has(raw.text) ? raw.text : "id";
|
|
294
|
+
else if (raw.kind === "number") word = "num";
|
|
295
|
+
else if (raw.kind === "string") word = "str";
|
|
296
|
+
else word = raw.text;
|
|
297
|
+
ids.push(table.intern(word));
|
|
298
|
+
lines.push(raw.line);
|
|
299
|
+
});
|
|
300
|
+
return {
|
|
301
|
+
ids: Uint32Array.from(ids),
|
|
302
|
+
lines: Uint32Array.from(lines)
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
// ../../packages/core/dist/reuse/code-files.js
|
|
306
|
+
/**
|
|
307
|
+
* Which files of a repository the duplicate detector reads: the source
|
|
308
|
+
* code a person wrote and maintains. Tests (repetitive by nature), generated code, lockfiles,
|
|
309
|
+
* minified bundles, binaries, documents and the folders every tool ignores are left out, whatever
|
|
310
|
+
* the language.
|
|
311
|
+
*/
|
|
312
|
+
const CODE_EXTENSION = wordSet(`js jsx mjs cjs ts tsx mts cts vue svelte astro py pyi rb go rs java kt kts scala groovy swift m mm
|
|
313
|
+
c h cc cpp cxx hpp hh cs php dart lua pl pm r ex exs hs nim zig cr sh bash zsh ps1 sql`);
|
|
314
|
+
const IGNORED_FOLDER = /* @__PURE__ */ new Set([
|
|
315
|
+
"node_modules",
|
|
316
|
+
"dist",
|
|
317
|
+
"build",
|
|
318
|
+
"out",
|
|
319
|
+
"target",
|
|
320
|
+
"vendor",
|
|
321
|
+
"third_party",
|
|
322
|
+
"thirdparty",
|
|
323
|
+
".git",
|
|
324
|
+
".next",
|
|
325
|
+
".nuxt",
|
|
326
|
+
".svelte-kit",
|
|
327
|
+
".turbo",
|
|
328
|
+
".cache",
|
|
329
|
+
".method",
|
|
330
|
+
".agentplugged",
|
|
331
|
+
"coverage",
|
|
332
|
+
"bin",
|
|
333
|
+
"obj",
|
|
334
|
+
"__pycache__",
|
|
335
|
+
".venv",
|
|
336
|
+
"venv",
|
|
337
|
+
"env",
|
|
338
|
+
"site-packages",
|
|
339
|
+
"generated",
|
|
340
|
+
"__generated__",
|
|
341
|
+
"migrations"
|
|
342
|
+
]);
|
|
343
|
+
const TEST_FILE = /(?:\.(?:test|spec|e2e|cy|stories)\.|_test\.|_spec\.|(?:^|\/)test_[^/]*\.py$|(?:^|\/)(?:tests?|__tests__|__mocks__|__snapshots__|spec|specs|e2e|fixtures?|testdata|testing)\/)/i;
|
|
344
|
+
/**
|
|
345
|
+
* Helpers that only tests use: `__helpers__/`, `pair.test-support.ts`, a `*-testkit/` folder. They
|
|
346
|
+
* repeat the code they stand in for by design, like the tests.
|
|
347
|
+
*/
|
|
348
|
+
const TEST_SUPPORT = /(?:\.test-(?:support|utils|helpers)\.|(?:^|\/)(?:__helpers__|(?:[\w.]+-)?testkit)\/)/i;
|
|
349
|
+
/** A folder starting with a dot (`.claude`, `.github`, `.husky`) holds tools and settings, not the product. */
|
|
350
|
+
const isHidden = (folder) => folder.length > 1 && folder.startsWith(".") && folder !== "..";
|
|
351
|
+
/** `OrderServiceTest.java`, `CartSpec.scala`: a capitalised suffix after a word (`Latest.java` is not one). */
|
|
352
|
+
const TEST_CLASS = /[a-z0-9](?:Test|Tests|Spec)\.(?:java|kt|cs|scala|swift|php)$/;
|
|
353
|
+
const GENERATED_FILE = /(?:\.min\.|\.bundle\.|\.generated\.|\.gen\.|\.g\.(?:cs|dart)$|\.designer\.cs$|\.pb\.go$|_pb2(?:_grpc)?\.py$|_pb\.(?:js|ts)$|\.d\.ts$|\.snap$)/i;
|
|
354
|
+
/** The largest file read (bytes): beyond, it is a bundle or a data dump, not code a person keeps. */
|
|
355
|
+
const MAX_FILE_BYTES = 2e5;
|
|
356
|
+
/** True when the detector reads the file: source code, not a test, not generated, not ignored. */
|
|
357
|
+
function isCodePath(path) {
|
|
358
|
+
const p = path.replaceAll("\\", "/");
|
|
359
|
+
const parts = p.split("/");
|
|
360
|
+
const name = parts.at(-1) ?? "";
|
|
361
|
+
const extension = name.includes(".") ? (name.split(".").at(-1) ?? "").toLowerCase() : "";
|
|
362
|
+
if (!CODE_EXTENSION.has(extension)) return false;
|
|
363
|
+
if (parts.slice(0, -1).some((folder) => IGNORED_FOLDER.has(folder.toLowerCase()) || isHidden(folder))) return false;
|
|
364
|
+
return !TEST_FILE.test(p) && !TEST_SUPPORT.test(p) && !TEST_CLASS.test(name) && !GENERATED_FILE.test(name);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* True when a file's text is not code to compare: a binary (a NUL), or minified (very long lines).
|
|
368
|
+
*/
|
|
369
|
+
function isReadableCode(text) {
|
|
370
|
+
if (text.length === 0 || text.length > 2e5 || text.includes("\0")) return false;
|
|
371
|
+
const lines = text.split("\n").length;
|
|
372
|
+
return text.length / lines <= 300;
|
|
373
|
+
}
|
|
374
|
+
const TEST_NAME = /(?:\.(?:tests?|specs?|e2e|cy)\.[a-z0-9]+$|[_-](?:test|spec)\.[a-z0-9]+$|(?:^|\/)test_[^/]*\.py$)/i;
|
|
375
|
+
/** `OrderServiceTest.java`, `CartSpec.scala`: a capitalised suffix after a word; `Spec` only where it is a test style. */
|
|
376
|
+
const TEST_CLASS_NAME = /[a-z0-9](?:Tests?\.(?:java|kt|cs|scala|swift|php|groovy)|Spec\.(?:scala|groovy|kt))$/;
|
|
377
|
+
const TEST_FOLDER = /(?:^|\/)(?:tests?|__tests__|spec|specs|e2e)\//i;
|
|
378
|
+
/** Folders that hold what others wrote or a tool made: no test of the product lives there. */
|
|
379
|
+
const NOT_THE_PRODUCT = /* @__PURE__ */ new Set([
|
|
380
|
+
"node_modules",
|
|
381
|
+
"dist",
|
|
382
|
+
"vendor",
|
|
383
|
+
"third_party",
|
|
384
|
+
"thirdparty",
|
|
385
|
+
".git",
|
|
386
|
+
"coverage",
|
|
387
|
+
"site-packages",
|
|
388
|
+
"__pycache__",
|
|
389
|
+
".venv",
|
|
390
|
+
"venv",
|
|
391
|
+
".next",
|
|
392
|
+
".nuxt",
|
|
393
|
+
".svelte-kit",
|
|
394
|
+
".turbo",
|
|
395
|
+
".cache",
|
|
396
|
+
".agentplugged"
|
|
397
|
+
]);
|
|
398
|
+
/**
|
|
399
|
+
* True when the path is a file of tests in a language (the guard before a write): named
|
|
400
|
+
* as one (`a.test.ts`, `a_test.go`, `test_a.py`, `ATest.java`) or, when `folders` is set, inside a
|
|
401
|
+
* folder of tests (`tests/`, `__tests__/`, `spec/`, `e2e/`). `path` is relative to the product:
|
|
402
|
+
* the folders above it say nothing. Generated files, snapshots and what others wrote are no test files.
|
|
403
|
+
*/
|
|
404
|
+
function isTestPath(path, folders) {
|
|
405
|
+
const p = path.replaceAll("\\", "/");
|
|
406
|
+
const parts = p.split("/");
|
|
407
|
+
const name = parts.at(-1) ?? "";
|
|
408
|
+
const extension = name.includes(".") ? (name.split(".").at(-1) ?? "").toLowerCase() : "";
|
|
409
|
+
if (!CODE_EXTENSION.has(extension) || GENERATED_FILE.test(name)) return false;
|
|
410
|
+
if (parts.slice(0, -1).some((folder) => NOT_THE_PRODUCT.has(folder.toLowerCase()))) return false;
|
|
411
|
+
return TEST_NAME.test(p) || TEST_CLASS_NAME.test(name) || folders && TEST_FOLDER.test(p);
|
|
412
|
+
}
|
|
413
|
+
// ../../packages/core/dist/reuse/twins.js
|
|
414
|
+
const MODIFIERS = "(?:(?:public|private|protected|internal|static|final|abstract|virtual|override|open|async|suspend|inline|operator|sealed|synchronized|export|default|pub(?:\\([^)]*\\))?|unsafe)\\s+)";
|
|
415
|
+
const PATTERNS = [
|
|
416
|
+
new RegExp(`^\\s*${MODIFIERS}*function\\s*\\*?\\s*([A-Za-z_$][\\w$]*)\\s*(?:<[^>(]*>)?\\s*\\(([^)]*)\\)`),
|
|
417
|
+
/^\s*(?:async\s+)?def\s+(?:self\.)?([A-Za-z_]\w*[?!]?)\s*(?:\(([^)]*)\))?/,
|
|
418
|
+
/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*\(([^)]*)\)/,
|
|
419
|
+
new RegExp(`^\\s*${MODIFIERS}*(?:fn|fun)\\s+(?:<[^>]*>\\s*)?([A-Za-z_]\\w*)\\s*(?:<[^>(]*>)?\\s*\\(([^)]*)\\)`),
|
|
420
|
+
/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?(?:\(([^)]*)\)|([A-Za-z_$][\w$]*))\s*(?::[^=]+)?=>/,
|
|
421
|
+
new RegExp(`^\\s*${MODIFIERS}+[\\w<>\\[\\],.?*&:\\s]+?\\s+([A-Za-z_]\\w*)\\s*\\(([^)]*)\\)\\s*(?:throws [\\w, .]+)?\\s*(?:\\{|=>|$)`),
|
|
422
|
+
/^\s{2,}(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*(?::\s*[^{=]+)?\{\s*$/
|
|
423
|
+
];
|
|
424
|
+
/** Names that say nothing about what a function does. */
|
|
425
|
+
const GENERIC = wordSet(`constructor main run init setup start stop test render handle handler get set add remove update
|
|
426
|
+
delete create build parse format validate check apply call exec execute map filter reduce toString
|
|
427
|
+
equals hashCode compare close open read write load save new default index each next`);
|
|
428
|
+
const NOT_A_FUNCTION = /* @__PURE__ */ new Set([
|
|
429
|
+
"if",
|
|
430
|
+
"for",
|
|
431
|
+
"while",
|
|
432
|
+
"switch",
|
|
433
|
+
"catch",
|
|
434
|
+
"return",
|
|
435
|
+
"function",
|
|
436
|
+
"else"
|
|
437
|
+
]);
|
|
438
|
+
/** The functions a file declares, one per declaration line. */
|
|
439
|
+
function functionsOf(path, text) {
|
|
440
|
+
const found = [];
|
|
441
|
+
const lines = text.split("\n");
|
|
442
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
443
|
+
const line = lines[index];
|
|
444
|
+
if (line.length > 400) continue;
|
|
445
|
+
for (const pattern of PATTERNS) {
|
|
446
|
+
const match = pattern.exec(line);
|
|
447
|
+
const name = match?.[1];
|
|
448
|
+
if (match === null || name === void 0 || NOT_A_FUNCTION.has(name)) continue;
|
|
449
|
+
const raw = match[2] ?? match[3] ?? "";
|
|
450
|
+
found.push({
|
|
451
|
+
name,
|
|
452
|
+
params: paramsOf(raw, path),
|
|
453
|
+
path,
|
|
454
|
+
line: index + 1,
|
|
455
|
+
nested: /^\s/.test(line)
|
|
456
|
+
});
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return found;
|
|
461
|
+
}
|
|
462
|
+
const PARAM_MODIFIERS = /* @__PURE__ */ new Set([
|
|
463
|
+
"final",
|
|
464
|
+
"const",
|
|
465
|
+
"mut",
|
|
466
|
+
"ref",
|
|
467
|
+
"val",
|
|
468
|
+
"var",
|
|
469
|
+
"readonly",
|
|
470
|
+
"in",
|
|
471
|
+
"out"
|
|
472
|
+
]);
|
|
473
|
+
/**
|
|
474
|
+
* The names of the parameters, not their types: `name: Type` and Go's `name Type` give the first
|
|
475
|
+
* word, `Type name` (Java, C, C#) the last.
|
|
476
|
+
*/
|
|
477
|
+
function paramsOf(raw, path) {
|
|
478
|
+
const nameFirst = /\.go$/i.test(path);
|
|
479
|
+
return raw.split(",").map((part) => {
|
|
480
|
+
const bare = part.split("=")[0] ?? "";
|
|
481
|
+
const words = ((bare.split(":")[0] ?? "").match(/[A-Za-z_$][\w$]*/g) ?? []).filter((word) => !PARAM_MODIFIERS.has(word));
|
|
482
|
+
return bare.includes(":") || nameFirst ? words[0] : words.at(-1);
|
|
483
|
+
}).filter((name) => name !== void 0 && name !== "self" && name !== "this");
|
|
484
|
+
}
|
|
485
|
+
/** A name in lower-case words: `getUserById` and `get_user_by_id` are the same. */
|
|
486
|
+
function nameWords(name) {
|
|
487
|
+
return name.replace(/([a-z\d])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z\d]+/).filter((word) => word !== "");
|
|
488
|
+
}
|
|
489
|
+
function distance(a, b) {
|
|
490
|
+
if (a === b) return 0;
|
|
491
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
492
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
493
|
+
const row = [i];
|
|
494
|
+
for (let j = 1; j <= b.length; j += 1) row[j] = Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
495
|
+
previous = row;
|
|
496
|
+
}
|
|
497
|
+
return previous[b.length];
|
|
498
|
+
}
|
|
499
|
+
/** Closeness (0 to 1) of two names, once split into words. */
|
|
500
|
+
function nameLikeness(a, b) {
|
|
501
|
+
const left = nameWords(a).join(" ");
|
|
502
|
+
const right = nameWords(b).join(" ");
|
|
503
|
+
const longest = Math.max(left.length, right.length);
|
|
504
|
+
return longest === 0 ? 0 : 1 - distance(left, right) / longest;
|
|
505
|
+
}
|
|
506
|
+
/** Closeness of the parameters: shared names over the names of the longer list. */
|
|
507
|
+
function paramsLikeness(a, b) {
|
|
508
|
+
const longest = Math.max(a.length, b.length);
|
|
509
|
+
if (longest === 0) return 0;
|
|
510
|
+
const names = new Set(b.map((name) => nameWords(name).join(" ")));
|
|
511
|
+
return a.filter((name) => names.has(nameWords(name).join(" "))).length / longest;
|
|
512
|
+
}
|
|
513
|
+
/** Languages whose functions all live inside a class: an indented declaration is then the norm. */
|
|
514
|
+
const CLASS_ONLY = /\.(?:java|kt|kts|scala|groovy|cs|php|swift|dart)$/i;
|
|
515
|
+
/**
|
|
516
|
+
* Two methods (or inner functions) of the same name in different files are the two sides of an
|
|
517
|
+
* interface, a port and its adapter: not a function written twice. Only a declaration other files
|
|
518
|
+
* could import counts, on one side at least.
|
|
519
|
+
*/
|
|
520
|
+
function bothInside(a, b) {
|
|
521
|
+
return a.nested && b.nested && !CLASS_ONLY.test(a.path) && !CLASS_ONLY.test(b.path);
|
|
522
|
+
}
|
|
523
|
+
function meaningful(name) {
|
|
524
|
+
const words = nameWords(name);
|
|
525
|
+
return !GENERIC.has(name) && (words.length >= 2 || name.length >= 8);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* The functions of `added` (declared on a line the change added) that look like a function of the
|
|
529
|
+
* repository: the same name in words, or a close name with close parameters. Never the function
|
|
530
|
+
* itself; generic names (`get`, `run`, `handle`) and short ones say nothing and are left out.
|
|
531
|
+
*/
|
|
532
|
+
function findTwins(files, added) {
|
|
533
|
+
const all = files.flatMap((file) => functionsOf(file.path, file.text));
|
|
534
|
+
const fresh = all.filter((fn) => added.get(fn.path)?.has(fn.line) === true && meaningful(fn.name));
|
|
535
|
+
if (fresh.length === 0) return [];
|
|
536
|
+
const byWord = /* @__PURE__ */ new Map();
|
|
537
|
+
for (const fn of all) {
|
|
538
|
+
if (!meaningful(fn.name)) continue;
|
|
539
|
+
for (const word of new Set(nameWords(fn.name))) {
|
|
540
|
+
if (word.length < 3) continue;
|
|
541
|
+
const list = byWord.get(word);
|
|
542
|
+
if (list === void 0) byWord.set(word, [fn]);
|
|
543
|
+
else if (list.length < 300) list.push(fn);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const twins = [];
|
|
547
|
+
for (const fn of fresh) {
|
|
548
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
549
|
+
for (const word of nameWords(fn.name)) for (const other of byWord.get(word) ?? []) candidates.add(other);
|
|
550
|
+
let best;
|
|
551
|
+
for (const other of candidates) {
|
|
552
|
+
if (other.path === fn.path && other.line === fn.line) continue;
|
|
553
|
+
if (bothInside(fn, other)) continue;
|
|
554
|
+
if (added.get(other.path)?.has(other.line) === true && (other.path < fn.path || other.path === fn.path && other.line < fn.line)) continue;
|
|
555
|
+
const likeness = nameLikeness(fn.name, other.name);
|
|
556
|
+
const same = nameWords(fn.name).join(" ") === nameWords(other.name).join(" ");
|
|
557
|
+
let why;
|
|
558
|
+
if (same) why = "same_name";
|
|
559
|
+
else if (likeness >= .8 && paramsLikeness(fn.params, other.params) >= .5) why = "close_name";
|
|
560
|
+
if (why === void 0) continue;
|
|
561
|
+
const score = (same ? 2 : 1) + likeness;
|
|
562
|
+
if (best === void 0 || score > best.score) best = {
|
|
563
|
+
fn: other,
|
|
564
|
+
why,
|
|
565
|
+
score
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
if (best !== void 0) twins.push({
|
|
569
|
+
name: fn.name,
|
|
570
|
+
path: fn.path,
|
|
571
|
+
line: fn.line,
|
|
572
|
+
twin: {
|
|
573
|
+
name: best.fn.name,
|
|
574
|
+
path: best.fn.path,
|
|
575
|
+
line: best.fn.line
|
|
576
|
+
},
|
|
577
|
+
why: best.why
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
return twins.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line).slice(0, 10);
|
|
581
|
+
}
|
|
582
|
+
// ../../packages/core/dist/project-profile/bricks.js
|
|
583
|
+
/** Most bricks kept: past it the registry is cut, never a failure. */
|
|
584
|
+
const MAX_BRICKS = 2e4;
|
|
585
|
+
const SIG_MAX = 100;
|
|
586
|
+
const TYPE_PATTERN = /^\s*(?:(?:export|default|public|private|protected|internal|pub(?:\([^)]*\))?|abstract|final|sealed|static|data|open|partial|declare)\s+)*(class|interface|struct|enum|trait|type|record|protocol|object|union)\s+([A-Za-z_$][\w$]*)/;
|
|
587
|
+
/** Names a person would not search for (the same ones the twin detector ignores as generic). */
|
|
588
|
+
const SKIP_NAME = /* @__PURE__ */ new Set([
|
|
589
|
+
"constructor",
|
|
590
|
+
"main",
|
|
591
|
+
"default",
|
|
592
|
+
"index"
|
|
593
|
+
]);
|
|
594
|
+
/**
|
|
595
|
+
* A function whose parameter list runs over several lines: the declaration line opens the
|
|
596
|
+
* parenthesis and does not close it (`function f(`, `def f(`, `const f = async (`).
|
|
597
|
+
*/
|
|
598
|
+
const OPEN_DECLARATION = /^(?:export\s+)?(?:default\s+)?(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:(?:function\s*\*?|def|func|fn|fun)\s+([A-Za-z_$][\w$]*)|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?)\s*(?:<[^>(]*>)?\s*\(([^)]*)$/;
|
|
599
|
+
/** The names of the parameters listed on the lines after an opened parenthesis. */
|
|
600
|
+
function multilineParams(first, lines, from) {
|
|
601
|
+
let raw = first;
|
|
602
|
+
for (let next = from; next < Math.min(lines.length, from + 14); next += 1) {
|
|
603
|
+
const line = lines[next];
|
|
604
|
+
const close = line.indexOf(")");
|
|
605
|
+
if (close !== -1) {
|
|
606
|
+
raw += ` ${line.slice(0, close)}`;
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
raw += ` ${line}`;
|
|
610
|
+
}
|
|
611
|
+
return splitTopLevel(raw).map((part) => /[A-Za-z_$][\w$]*/.exec((part.split(":")[0] ?? "").trim())?.[0]).filter((name) => name !== void 0 && name !== "readonly");
|
|
612
|
+
}
|
|
613
|
+
/** The parts of a list split at the commas that are not inside <>, () or []. */
|
|
614
|
+
function splitTopLevel(text) {
|
|
615
|
+
const parts = [];
|
|
616
|
+
let depth = 0;
|
|
617
|
+
let current = "";
|
|
618
|
+
for (const char of text) {
|
|
619
|
+
if ("<([{".includes(char)) depth += 1;
|
|
620
|
+
else if (">)]}".includes(char)) depth = Math.max(0, depth - 1);
|
|
621
|
+
if (char === "," && depth === 0) {
|
|
622
|
+
parts.push(current);
|
|
623
|
+
current = "";
|
|
624
|
+
} else current += char;
|
|
625
|
+
}
|
|
626
|
+
parts.push(current);
|
|
627
|
+
return parts;
|
|
628
|
+
}
|
|
629
|
+
/** The bricks of one file: the functions other files could import, and the types. */
|
|
630
|
+
function bricksOf(path, text) {
|
|
631
|
+
const found = [];
|
|
632
|
+
const seen = /* @__PURE__ */ new Set();
|
|
633
|
+
const lines = text.split("\n");
|
|
634
|
+
for (const fn of functionsOf(path, text)) {
|
|
635
|
+
if (fn.nested || SKIP_NAME.has(fn.name)) continue;
|
|
636
|
+
seen.add(`${fn.line}`);
|
|
637
|
+
found.push({
|
|
638
|
+
name: fn.name,
|
|
639
|
+
kind: "fn",
|
|
640
|
+
path,
|
|
641
|
+
line: fn.line,
|
|
642
|
+
sig: `${fn.name}(${fn.params.join(", ")})`.slice(0, SIG_MAX)
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
646
|
+
const line = lines[index];
|
|
647
|
+
if (line.length > 300 || seen.has(`${index + 1}`)) continue;
|
|
648
|
+
const open = OPEN_DECLARATION.exec(line);
|
|
649
|
+
const opened = open?.[1] ?? open?.[2];
|
|
650
|
+
if (open === null || opened === void 0 || SKIP_NAME.has(opened)) continue;
|
|
651
|
+
found.push({
|
|
652
|
+
name: opened,
|
|
653
|
+
kind: "fn",
|
|
654
|
+
path,
|
|
655
|
+
line: index + 1,
|
|
656
|
+
sig: `${opened}(${multilineParams(open[3] ?? "", lines, index + 1).join(", ")})`.slice(0, SIG_MAX)
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
found.sort((a, b) => a.line - b.line);
|
|
660
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
661
|
+
const line = lines[index];
|
|
662
|
+
if (line.length > 300) continue;
|
|
663
|
+
const match = TYPE_PATTERN.exec(line);
|
|
664
|
+
const name = match?.[2];
|
|
665
|
+
if (match === null || name === void 0 || name.length < 3) continue;
|
|
666
|
+
if (/^\s*[,}]/.test(line.slice(match[0].length))) continue;
|
|
667
|
+
if (match[1] === "type" && (/^\s/.test(line) || !/^(?:export\s+)?type\s+[A-Z]/.test(line)) && !(/\.go$/.test(path) && !/^\s/.test(line))) continue;
|
|
668
|
+
found.push({
|
|
669
|
+
name,
|
|
670
|
+
kind: "type",
|
|
671
|
+
path,
|
|
672
|
+
line: index + 1,
|
|
673
|
+
sig: line.trim().replace(/\s*[{(=].*$/, "").slice(0, SIG_MAX)
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return found;
|
|
677
|
+
}
|
|
678
|
+
/** The registry of a set of files (tests, generated code and the like are left out). */
|
|
679
|
+
function buildBricks(files) {
|
|
680
|
+
const all = [];
|
|
681
|
+
for (const file of files) {
|
|
682
|
+
if (!isCodePath(file.path) || !isReadableCode(file.text)) continue;
|
|
683
|
+
all.push(...bricksOf(file.path, file.text));
|
|
684
|
+
if (all.length >= 2e4) break;
|
|
685
|
+
}
|
|
686
|
+
return all.slice(0, MAX_BRICKS);
|
|
687
|
+
}
|
|
688
|
+
/** Largest file read to build the registry (bytes). */
|
|
689
|
+
const BRICK_FILE_BYTES = MAX_FILE_BYTES;
|
|
690
|
+
/** Lower-case search words of a request: names are split, plurals folded, filler dropped. */
|
|
691
|
+
function searchWords(words) {
|
|
692
|
+
const filler = new Set("the a an of for to and or in is it does do into from with that this all any how we there already exist exists".split(" "));
|
|
693
|
+
const out = /* @__PURE__ */ new Set();
|
|
694
|
+
for (const word of words.flatMap((entry) => nameWords(entry))) {
|
|
695
|
+
if (word.length < 2 || filler.has(word)) continue;
|
|
696
|
+
out.add(stem(word));
|
|
697
|
+
}
|
|
698
|
+
return [...out];
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* A word folded to its root, so that « parsing dates » finds `parseDate`: plural, `-ing`, `-ed`, a
|
|
702
|
+
* final `e` and a doubled last letter are dropped. Both sides of a comparison go through it.
|
|
703
|
+
*/
|
|
704
|
+
const stem = (word) => {
|
|
705
|
+
let root = word;
|
|
706
|
+
if (root.length > 4 && root.endsWith("ies")) root = `${root.slice(0, -3)}y`;
|
|
707
|
+
else if (root.length > 3 && root.endsWith("s") && !root.endsWith("ss")) root = root.slice(0, -1);
|
|
708
|
+
if (root.length > 5 && root.endsWith("ing")) root = root.slice(0, -3);
|
|
709
|
+
else if (root.length > 4 && root.endsWith("ed")) root = root.slice(0, -2);
|
|
710
|
+
if (root.length > 3 && root.endsWith("e")) root = root.slice(0, -1);
|
|
711
|
+
if (root.length > 4 && /([^aeiou])\1$/.test(root) && !root.endsWith("ss")) root = root.slice(0, -1);
|
|
712
|
+
return root;
|
|
713
|
+
};
|
|
714
|
+
/**
|
|
715
|
+
* The bricks that answer a request: those whose name carries the most of its words (all of them
|
|
716
|
+
* first), then those whose path or signature does. Nothing is returned when no word matches.
|
|
717
|
+
*/
|
|
718
|
+
function findBricks(bricks, request) {
|
|
719
|
+
const wanted = searchWords(request);
|
|
720
|
+
if (wanted.length === 0) return [];
|
|
721
|
+
const hits = [];
|
|
722
|
+
for (const brick of bricks) {
|
|
723
|
+
const inName = new Set(nameWords(brick.name).map(stem));
|
|
724
|
+
const inPath = new Set(nameWords(brick.path).map(stem));
|
|
725
|
+
const inSig = new Set(nameWords(brick.sig).map(stem));
|
|
726
|
+
let score = 0;
|
|
727
|
+
let named = 0;
|
|
728
|
+
let covered = 0;
|
|
729
|
+
for (const word of wanted) if (inName.has(word)) {
|
|
730
|
+
score += 3;
|
|
731
|
+
named += 1;
|
|
732
|
+
covered += 1;
|
|
733
|
+
} else if (inPath.has(word)) {
|
|
734
|
+
score += 1;
|
|
735
|
+
covered += 1;
|
|
736
|
+
} else if (inSig.has(word)) {
|
|
737
|
+
score += .5;
|
|
738
|
+
covered += 1;
|
|
739
|
+
}
|
|
740
|
+
if (covered < Math.ceil(wanted.length * .6)) continue;
|
|
741
|
+
if (named === 0 && wanted.length > 1) continue;
|
|
742
|
+
if (named === wanted.length) score += 2;
|
|
743
|
+
hits.push({
|
|
744
|
+
brick,
|
|
745
|
+
score
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
return hits.sort((a, b) => b.score - a.score || a.brick.path.localeCompare(b.brick.path) || a.brick.line - b.brick.line).slice(0, 8);
|
|
749
|
+
}
|
|
750
|
+
/** A single source that answers a request: every word of its concept is in the request. */
|
|
751
|
+
function matchingSources(sources, request) {
|
|
752
|
+
const wanted = new Set(searchWords(request));
|
|
753
|
+
return sources.filter((source) => {
|
|
754
|
+
const words = searchWords([source.concept]);
|
|
755
|
+
return words.length > 0 && words.every((word) => wanted.has(word));
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/** True when `path` is the owner (a file) or lies under it (a folder written with a final `/`). */
|
|
759
|
+
function isOwner(source, path) {
|
|
760
|
+
const owner = source.owner.replaceAll("\\", "/");
|
|
761
|
+
const here = path.replaceAll("\\", "/");
|
|
762
|
+
return owner.endsWith("/") ? here.startsWith(owner) : here === owner;
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* The functions of `added` (a path and its added lines) whose name carries every word of a single
|
|
766
|
+
* source's concept while their file is not its owner: the concept has one home, and this is a second.
|
|
767
|
+
*/
|
|
768
|
+
function sourceClashes(files, added, sources) {
|
|
769
|
+
if (sources.length === 0) return [];
|
|
770
|
+
const clashes = [];
|
|
771
|
+
for (const file of files) {
|
|
772
|
+
const lines = added.get(file.path);
|
|
773
|
+
if (lines === void 0) continue;
|
|
774
|
+
for (const brick of bricksOf(file.path, file.text)) {
|
|
775
|
+
if (!lines.has(brick.line)) continue;
|
|
776
|
+
const words = new Set(nameWords(brick.name).map(stem));
|
|
777
|
+
for (const source of sources) {
|
|
778
|
+
if (isOwner(source, brick.path)) continue;
|
|
779
|
+
const concept = searchWords([source.concept]);
|
|
780
|
+
if (concept.length > 0 && concept.every((word) => words.has(word))) {
|
|
781
|
+
clashes.push({
|
|
782
|
+
name: brick.name,
|
|
783
|
+
path: brick.path,
|
|
784
|
+
line: brick.line,
|
|
785
|
+
concept: source.concept,
|
|
786
|
+
owner: source.owner
|
|
787
|
+
});
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return clashes;
|
|
794
|
+
}
|
|
795
|
+
// ../../packages/core/dist/project-profile/profile-file.js
|
|
796
|
+
/** The project profile: one short file, committed, written by the engine. */
|
|
797
|
+
const PROFILE_PATH = `${METHOD_DIR}/profile.md`;
|
|
798
|
+
/** The acceptance section for a place and how to reach it (one line each, never a secret). */
|
|
799
|
+
function acceptanceText(place, how) {
|
|
800
|
+
const line = how.replace(/[\r\n]+/g, " ").trim().slice(0, 200);
|
|
801
|
+
return `- Place: ${place}${line === "" ? "" : `\n- How: ${line}`}`;
|
|
802
|
+
}
|
|
803
|
+
const HEADER = "<!-- Written by agentplugged: `npx agentplugged docs profile refresh` rewrites every section but Single sources, Acceptance and Notes. -->";
|
|
804
|
+
const SOURCES_HEADING = "## Single sources";
|
|
805
|
+
const ACCEPTANCE_HEADING = "## Acceptance";
|
|
806
|
+
const NOTES_HEADING = "## Notes";
|
|
807
|
+
const cell = (text) => text.replace(/[|\r\n]+/g, " ").trim();
|
|
808
|
+
/** Rows kept per generated section: the file stays within `PROFILE_MAX_LINES` on a big product. */
|
|
809
|
+
const ROW_CAPS = {
|
|
810
|
+
stack: 5,
|
|
811
|
+
commands: 10,
|
|
812
|
+
layout: 2,
|
|
813
|
+
conventions: 8,
|
|
814
|
+
advice: 6
|
|
815
|
+
};
|
|
816
|
+
/** The generated sections of the profile, from what the scan found. */
|
|
817
|
+
function renderProfile(scan, kept) {
|
|
818
|
+
const lines = [
|
|
819
|
+
HEADER,
|
|
820
|
+
"# Project profile",
|
|
821
|
+
""
|
|
822
|
+
];
|
|
823
|
+
const section = (title, rows, empty) => {
|
|
824
|
+
if (rows.length === 0 && empty === void 0) return;
|
|
825
|
+
lines.push(`## ${title}`, ...rows.length === 0 ? [empty] : rows, "");
|
|
826
|
+
};
|
|
827
|
+
section("Stack", scan.stack.slice(0, ROW_CAPS.stack).map((row) => `- ${row}`), "- No code yet: run `npx agentplugged docs profile refresh` once the first story is merged.");
|
|
828
|
+
section("Commands", scan.commands.slice(0, ROW_CAPS.commands).map((command) => `- ${command.name}: \`${command.command}\` (${command.source})`), "- None found in the root manifests.");
|
|
829
|
+
section("Layout", scan.layout.slice(0, ROW_CAPS.layout).map((row) => `- ${row}`));
|
|
830
|
+
section("Conventions", scan.conventions.slice(0, ROW_CAPS.conventions).map((row) => `- ${row}`));
|
|
831
|
+
section("Advice", scan.advice.slice(0, ROW_CAPS.advice).map((item) => `- ${item.advice} Why: ${item.why}`));
|
|
832
|
+
lines.push(SOURCES_HEADING);
|
|
833
|
+
lines.push("One owner per concept: reuse it, never rewrite it elsewhere.");
|
|
834
|
+
lines.push("| Concept | Owner | Rule |", "| --- | --- | --- |");
|
|
835
|
+
for (const source of kept.sources) lines.push(`| ${cell(source.concept)} | ${cell(source.owner)} | ${cell(source.rule)} |`);
|
|
836
|
+
lines.push("", ACCEPTANCE_HEADING, kept.acceptance === "" ? "(not set)" : kept.acceptance.trimEnd());
|
|
837
|
+
lines.push("", NOTES_HEADING, kept.notes === "" ? "(none)" : kept.notes.trimEnd(), "");
|
|
838
|
+
return `${lines.join("\n")}`;
|
|
839
|
+
}
|
|
840
|
+
/** The single sources and the notes of an existing profile (what a refresh must keep). */
|
|
841
|
+
function parseKept(text) {
|
|
842
|
+
const body = text.replace(/\r\n/g, "\n");
|
|
843
|
+
const start = body.indexOf(`\n${SOURCES_HEADING}\n`);
|
|
844
|
+
const notesAt = body.lastIndexOf(`\n${NOTES_HEADING}\n`);
|
|
845
|
+
const sources = [];
|
|
846
|
+
if (start !== -1) {
|
|
847
|
+
const next = body.indexOf("\n## ", start + 1);
|
|
848
|
+
const end = next === -1 ? body.length : next;
|
|
849
|
+
for (const row of body.slice(start, end).split("\n")) {
|
|
850
|
+
const cells = row.split("|").slice(1, -1).map((part) => part.trim());
|
|
851
|
+
if (cells.length !== 3 || cells[0] === "Concept" || /^-+$/.test(cells[0])) continue;
|
|
852
|
+
const [concept, owner, rule] = cells;
|
|
853
|
+
if (concept !== "" && owner !== "") sources.push({
|
|
854
|
+
concept,
|
|
855
|
+
owner,
|
|
856
|
+
rule
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const acceptanceAt = body.indexOf(`\n${ACCEPTANCE_HEADING}\n`);
|
|
861
|
+
let acceptance = "";
|
|
862
|
+
if (acceptanceAt !== -1) {
|
|
863
|
+
const from = acceptanceAt + 13 + 2;
|
|
864
|
+
const stop = body.indexOf("\n## ", from);
|
|
865
|
+
acceptance = body.slice(from, stop === -1 ? body.length : stop).trim();
|
|
866
|
+
if (acceptance === "(not set)") acceptance = "";
|
|
867
|
+
}
|
|
868
|
+
const afterNotes = notesAt === -1 ? "" : body.slice(notesAt + 8 + 2);
|
|
869
|
+
const sourcesAfter = start > notesAt ? afterNotes.indexOf(`\n${SOURCES_HEADING}\n`) : -1;
|
|
870
|
+
let notes = (sourcesAfter === -1 ? afterNotes : afterNotes.slice(0, sourcesAfter)).trim();
|
|
871
|
+
if (notes === "(none)") notes = "";
|
|
872
|
+
return {
|
|
873
|
+
sources,
|
|
874
|
+
acceptance,
|
|
875
|
+
notes
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
/** The same profile with one single source added or replaced (the concept names it, case apart). */
|
|
879
|
+
function withSource(kept, source) {
|
|
880
|
+
const same = (candidate) => candidate.concept.toLowerCase() === source.concept.toLowerCase();
|
|
881
|
+
return {
|
|
882
|
+
...kept,
|
|
883
|
+
sources: [...kept.sources.filter((item) => !same(item)), source]
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
// ../../packages/core/dist/project-profile/scan.js
|
|
887
|
+
/** Longest manifest read (bytes): a larger one is data, not a manifest. */
|
|
888
|
+
const MAX_MANIFEST = 2e5;
|
|
889
|
+
const MAX_LAYOUT = 12;
|
|
890
|
+
/** Root entries that are not product folders. */
|
|
891
|
+
const NOT_LAYOUT = /* @__PURE__ */ new Set([
|
|
892
|
+
"node_modules",
|
|
893
|
+
"dist",
|
|
894
|
+
"build",
|
|
895
|
+
"out",
|
|
896
|
+
"target",
|
|
897
|
+
"vendor",
|
|
898
|
+
"coverage",
|
|
899
|
+
"tmp",
|
|
900
|
+
"temp",
|
|
901
|
+
"bin",
|
|
902
|
+
"obj",
|
|
903
|
+
"venv",
|
|
904
|
+
"env",
|
|
905
|
+
"docs",
|
|
906
|
+
"doc",
|
|
907
|
+
"scripts",
|
|
908
|
+
"public",
|
|
909
|
+
"assets",
|
|
910
|
+
"images",
|
|
911
|
+
"licenses",
|
|
912
|
+
"license"
|
|
913
|
+
]);
|
|
914
|
+
const TEST_FOLDERS = /* @__PURE__ */ new Set([
|
|
915
|
+
"test",
|
|
916
|
+
"tests",
|
|
917
|
+
"__tests__",
|
|
918
|
+
"spec",
|
|
919
|
+
"specs",
|
|
920
|
+
"e2e"
|
|
921
|
+
]);
|
|
922
|
+
function lower(entries) {
|
|
923
|
+
return new Map(entries.map((entry) => [entry.toLowerCase(), entry]));
|
|
924
|
+
}
|
|
925
|
+
/** The script a manifest names `name`, as a command, or undefined. */
|
|
926
|
+
const pick = (record, name) => {
|
|
927
|
+
const value = record?.[name];
|
|
928
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
929
|
+
};
|
|
930
|
+
const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
931
|
+
/** Libraries worth naming in the stack: a short, well-known set, found in a dependency list. */
|
|
932
|
+
const KNOWN_PACKAGES = [
|
|
933
|
+
["react", "React"],
|
|
934
|
+
["vue", "Vue"],
|
|
935
|
+
["svelte", "Svelte"],
|
|
936
|
+
["next", "Next.js"],
|
|
937
|
+
["nuxt", "Nuxt"],
|
|
938
|
+
["@angular/core", "Angular"],
|
|
939
|
+
["express", "Express"],
|
|
940
|
+
["fastify", "Fastify"],
|
|
941
|
+
["@nestjs/core", "NestJS"],
|
|
942
|
+
["vitest", "Vitest"],
|
|
943
|
+
["jest", "Jest"],
|
|
944
|
+
["mocha", "Mocha"],
|
|
945
|
+
["@playwright/test", "Playwright"],
|
|
946
|
+
["cypress", "Cypress"],
|
|
947
|
+
["@biomejs/biome", "Biome"],
|
|
948
|
+
["eslint", "ESLint"],
|
|
949
|
+
["prettier", "Prettier"]
|
|
950
|
+
];
|
|
951
|
+
async function scanNode(reader, out) {
|
|
952
|
+
const raw = await reader.text("package.json");
|
|
953
|
+
if (raw === void 0) return;
|
|
954
|
+
let manifest;
|
|
955
|
+
try {
|
|
956
|
+
manifest = asRecord(JSON.parse(raw));
|
|
957
|
+
} catch {
|
|
958
|
+
out.stack.push("Node.js project (package.json is not valid JSON)");
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (manifest === void 0) return;
|
|
962
|
+
const lockfiles = [
|
|
963
|
+
"pnpm-lock.yaml",
|
|
964
|
+
"yarn.lock",
|
|
965
|
+
"bun.lock",
|
|
966
|
+
"bun.lockb",
|
|
967
|
+
"package-lock.json"
|
|
968
|
+
].filter((name) => reader.entries.has(name));
|
|
969
|
+
const declared = pick(manifest, "packageManager")?.split("@")[0];
|
|
970
|
+
const manager = declared !== void 0 && declared !== "" ? declared : {
|
|
971
|
+
"pnpm-lock.yaml": "pnpm",
|
|
972
|
+
"yarn.lock": "yarn",
|
|
973
|
+
"bun.lock": "bun",
|
|
974
|
+
"bun.lockb": "bun",
|
|
975
|
+
"package-lock.json": "npm"
|
|
976
|
+
}[lockfiles[0] ?? ""] ?? "npm";
|
|
977
|
+
const typed = reader.entries.has("tsconfig.json");
|
|
978
|
+
const engines = pick(asRecord(manifest.engines), "node");
|
|
979
|
+
out.stack.push(`${typed ? "TypeScript" : "JavaScript"} on Node.js${engines === void 0 ? "" : ` ${engines}`}, ${manager} (package.json${lockfiles.length > 0 ? `, ${lockfiles[0]}` : ""})`);
|
|
980
|
+
const dependencies = {
|
|
981
|
+
...asRecord(manifest.dependencies),
|
|
982
|
+
...asRecord(manifest.devDependencies)
|
|
983
|
+
};
|
|
984
|
+
const known = KNOWN_PACKAGES.filter(([name]) => name in dependencies).map(([, label]) => label);
|
|
985
|
+
if (known.length > 0) out.stack.push(`Libraries: ${known.join(", ")} (package.json)`);
|
|
986
|
+
const scripts = asRecord(manifest.scripts);
|
|
987
|
+
const run = (name) => manager === "yarn" ? `yarn ${name}` : `${manager} run ${name}`;
|
|
988
|
+
const add = (kind, script, command = run(script)) => {
|
|
989
|
+
if (pick(scripts, script) !== void 0) out.commands.push({
|
|
990
|
+
name: kind,
|
|
991
|
+
command,
|
|
992
|
+
source: `package.json scripts.${script}`
|
|
993
|
+
});
|
|
994
|
+
};
|
|
995
|
+
add("test", "test", manager === "yarn" ? "yarn test" : `${manager} test`);
|
|
996
|
+
add("build", "build");
|
|
997
|
+
add("lint", "lint");
|
|
998
|
+
add("typecheck", "typecheck");
|
|
999
|
+
add("typecheck", "type-check");
|
|
1000
|
+
add("format", "format");
|
|
1001
|
+
add("run", "dev");
|
|
1002
|
+
if (pick(scripts, "dev") === void 0) add("run", "start");
|
|
1003
|
+
if (lockfiles.length > 1) out.advice.push({
|
|
1004
|
+
advice: `Pick one package manager (${manager}) and delete the other lockfiles.`,
|
|
1005
|
+
why: `${lockfiles.join(" and ")} are all committed: two managers rewrite each other's lockfile.`
|
|
1006
|
+
});
|
|
1007
|
+
else if (lockfiles.length === 1) out.advice.push({
|
|
1008
|
+
advice: `Install with ${manager} only.`,
|
|
1009
|
+
why: `${lockfiles[0]} is committed: another manager would rewrite it and change versions.`
|
|
1010
|
+
});
|
|
1011
|
+
if (manifest.workspaces !== void 0 || reader.entries.has("pnpm-workspace.yaml")) out.advice.push({
|
|
1012
|
+
advice: "Run a package's commands from that package, not from the root, when you touch one.",
|
|
1013
|
+
why: "the repository holds several packages (workspaces): the root command runs them all."
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
async function scanPython(reader, out) {
|
|
1017
|
+
const pyproject = await reader.text("pyproject.toml");
|
|
1018
|
+
const requirements = await reader.text("requirements.txt");
|
|
1019
|
+
const hasSetup = reader.entries.has("setup.py") || reader.entries.has("pipfile");
|
|
1020
|
+
if (pyproject === void 0 && requirements === void 0 && !hasSetup) return;
|
|
1021
|
+
const runner = reader.entries.has("uv.lock") ? "uv run " : reader.entries.has("poetry.lock") ? "poetry run " : "";
|
|
1022
|
+
const source = pyproject !== void 0 ? "pyproject.toml" : "requirements.txt";
|
|
1023
|
+
const text = `${pyproject ?? ""}\n${requirements ?? ""}`.toLowerCase();
|
|
1024
|
+
out.stack.push(`Python (${source}${runner === "" ? "" : `, ${runner.trim()}`})`);
|
|
1025
|
+
if (/pytest/.test(text)) out.commands.push({
|
|
1026
|
+
name: "test",
|
|
1027
|
+
command: `${runner}pytest`,
|
|
1028
|
+
source
|
|
1029
|
+
});
|
|
1030
|
+
if (/\[tool\.ruff|\bruff\b/.test(text)) out.commands.push({
|
|
1031
|
+
name: "lint",
|
|
1032
|
+
command: `${runner}ruff check`,
|
|
1033
|
+
source
|
|
1034
|
+
});
|
|
1035
|
+
if (/\[tool\.mypy|\bmypy\b/.test(text)) out.commands.push({
|
|
1036
|
+
name: "typecheck",
|
|
1037
|
+
command: `${runner}mypy .`,
|
|
1038
|
+
source
|
|
1039
|
+
});
|
|
1040
|
+
if (runner !== "") out.advice.push({
|
|
1041
|
+
advice: `Run Python tools through \`${runner.trim()}\`.`,
|
|
1042
|
+
why: `${runner.startsWith("uv") ? "uv.lock" : "poetry.lock"} is committed: a bare \`python\` or \`pytest\` runs outside the project environment.`
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
function scanSimple(reader, out) {
|
|
1046
|
+
const has = (name) => reader.entries.has(name);
|
|
1047
|
+
if (has("cargo.toml")) {
|
|
1048
|
+
out.stack.push("Rust (Cargo.toml)");
|
|
1049
|
+
out.commands.push({
|
|
1050
|
+
name: "test",
|
|
1051
|
+
command: "cargo test",
|
|
1052
|
+
source: "Cargo.toml"
|
|
1053
|
+
});
|
|
1054
|
+
out.commands.push({
|
|
1055
|
+
name: "build",
|
|
1056
|
+
command: "cargo build",
|
|
1057
|
+
source: "Cargo.toml"
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
if (has("go.mod")) {
|
|
1061
|
+
out.stack.push("Go (go.mod)");
|
|
1062
|
+
out.commands.push({
|
|
1063
|
+
name: "test",
|
|
1064
|
+
command: "go test ./...",
|
|
1065
|
+
source: "go.mod"
|
|
1066
|
+
});
|
|
1067
|
+
out.commands.push({
|
|
1068
|
+
name: "build",
|
|
1069
|
+
command: "go build ./...",
|
|
1070
|
+
source: "go.mod"
|
|
1071
|
+
});
|
|
1072
|
+
out.commands.push({
|
|
1073
|
+
name: "lint",
|
|
1074
|
+
command: "go vet ./...",
|
|
1075
|
+
source: "go.mod"
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
if (has("pom.xml")) {
|
|
1079
|
+
out.stack.push("Java (pom.xml)");
|
|
1080
|
+
out.commands.push({
|
|
1081
|
+
name: "test",
|
|
1082
|
+
command: "mvn test",
|
|
1083
|
+
source: "pom.xml"
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
const gradle = ["build.gradle", "build.gradle.kts"].find(has);
|
|
1087
|
+
if (gradle !== void 0) {
|
|
1088
|
+
const wrapper = has("gradlew") ? "./gradlew" : "gradle";
|
|
1089
|
+
out.stack.push(`JVM, Gradle (${gradle})`);
|
|
1090
|
+
out.commands.push({
|
|
1091
|
+
name: "test",
|
|
1092
|
+
command: `${wrapper} test`,
|
|
1093
|
+
source: gradle
|
|
1094
|
+
});
|
|
1095
|
+
out.commands.push({
|
|
1096
|
+
name: "build",
|
|
1097
|
+
command: `${wrapper} build`,
|
|
1098
|
+
source: gradle
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
const dotnet = [...reader.entries].find((name) => /\.(?:sln|slnx|csproj|fsproj)$/.test(name));
|
|
1102
|
+
if (dotnet !== void 0) {
|
|
1103
|
+
out.stack.push(`.NET (${dotnet})`);
|
|
1104
|
+
out.commands.push({
|
|
1105
|
+
name: "test",
|
|
1106
|
+
command: "dotnet test",
|
|
1107
|
+
source: dotnet
|
|
1108
|
+
});
|
|
1109
|
+
out.commands.push({
|
|
1110
|
+
name: "build",
|
|
1111
|
+
command: "dotnet build",
|
|
1112
|
+
source: dotnet
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
if (has("gemfile")) {
|
|
1116
|
+
out.stack.push("Ruby (Gemfile)");
|
|
1117
|
+
if (has(".rspec")) out.commands.push({
|
|
1118
|
+
name: "test",
|
|
1119
|
+
command: "bundle exec rspec",
|
|
1120
|
+
source: ".rspec"
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
if (has("mix.exs")) {
|
|
1124
|
+
out.stack.push("Elixir (mix.exs)");
|
|
1125
|
+
out.commands.push({
|
|
1126
|
+
name: "test",
|
|
1127
|
+
command: "mix test",
|
|
1128
|
+
source: "mix.exs"
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
if (has("package.swift")) {
|
|
1132
|
+
out.stack.push("Swift (Package.swift)");
|
|
1133
|
+
out.commands.push({
|
|
1134
|
+
name: "test",
|
|
1135
|
+
command: "swift test",
|
|
1136
|
+
source: "Package.swift"
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
if (has("pubspec.yaml")) out.stack.push("Dart (pubspec.yaml)");
|
|
1140
|
+
}
|
|
1141
|
+
async function scanComposer(reader, out) {
|
|
1142
|
+
const raw = await reader.text("composer.json");
|
|
1143
|
+
if (raw === void 0) return;
|
|
1144
|
+
out.stack.push("PHP (composer.json)");
|
|
1145
|
+
try {
|
|
1146
|
+
if (asRecord(asRecord(JSON.parse(raw))?.scripts)?.test !== void 0) out.commands.push({
|
|
1147
|
+
name: "test",
|
|
1148
|
+
command: "composer test",
|
|
1149
|
+
source: "composer.json scripts.test"
|
|
1150
|
+
});
|
|
1151
|
+
} catch {}
|
|
1152
|
+
}
|
|
1153
|
+
/** The targets of a Makefile the profile names (a target is a command only when it is written). */
|
|
1154
|
+
async function scanMake(reader, out) {
|
|
1155
|
+
const text = await reader.text("Makefile");
|
|
1156
|
+
if (text === void 0) return;
|
|
1157
|
+
const targets = new Set([...text.matchAll(/^([a-z][\w-]*)\s*:(?!=)/gm)].map((match) => match[1]));
|
|
1158
|
+
for (const [kind, target] of [
|
|
1159
|
+
["test", "test"],
|
|
1160
|
+
["build", "build"],
|
|
1161
|
+
["lint", "lint"],
|
|
1162
|
+
["format", "fmt"],
|
|
1163
|
+
["format", "format"]
|
|
1164
|
+
]) if (targets.has(target) && !out.commands.some((command) => command.name === kind)) out.commands.push({
|
|
1165
|
+
name: kind,
|
|
1166
|
+
command: `make ${target}`,
|
|
1167
|
+
source: `Makefile ${target}:`
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
const TOOLING = [
|
|
1171
|
+
[
|
|
1172
|
+
["biome.json", "biome.jsonc"],
|
|
1173
|
+
"Biome formats and lints",
|
|
1174
|
+
"its config is committed"
|
|
1175
|
+
],
|
|
1176
|
+
[
|
|
1177
|
+
[
|
|
1178
|
+
".prettierrc",
|
|
1179
|
+
".prettierrc.json",
|
|
1180
|
+
".prettierrc.js",
|
|
1181
|
+
"prettier.config.js"
|
|
1182
|
+
],
|
|
1183
|
+
"Prettier formats",
|
|
1184
|
+
"its config is committed"
|
|
1185
|
+
],
|
|
1186
|
+
[
|
|
1187
|
+
[
|
|
1188
|
+
".eslintrc",
|
|
1189
|
+
".eslintrc.json",
|
|
1190
|
+
".eslintrc.js",
|
|
1191
|
+
"eslint.config.js",
|
|
1192
|
+
"eslint.config.mjs"
|
|
1193
|
+
],
|
|
1194
|
+
"ESLint lints",
|
|
1195
|
+
"its config is committed"
|
|
1196
|
+
],
|
|
1197
|
+
[
|
|
1198
|
+
["rustfmt.toml", ".rustfmt.toml"],
|
|
1199
|
+
"rustfmt formats",
|
|
1200
|
+
"its config is committed"
|
|
1201
|
+
],
|
|
1202
|
+
[
|
|
1203
|
+
[".golangci.yml", ".golangci.yaml"],
|
|
1204
|
+
"golangci-lint lints",
|
|
1205
|
+
"its config is committed"
|
|
1206
|
+
],
|
|
1207
|
+
[
|
|
1208
|
+
[".rubocop.yml"],
|
|
1209
|
+
"RuboCop lints",
|
|
1210
|
+
"its config is committed"
|
|
1211
|
+
],
|
|
1212
|
+
[
|
|
1213
|
+
[".editorconfig"],
|
|
1214
|
+
"EditorConfig sets indentation and line endings",
|
|
1215
|
+
".editorconfig is committed"
|
|
1216
|
+
]
|
|
1217
|
+
];
|
|
1218
|
+
const CI_FILES = [
|
|
1219
|
+
".gitlab-ci.yml",
|
|
1220
|
+
"azure-pipelines.yml",
|
|
1221
|
+
"jenkinsfile",
|
|
1222
|
+
".travis.yml",
|
|
1223
|
+
"bitbucket-pipelines.yml",
|
|
1224
|
+
"appveyor.yml"
|
|
1225
|
+
];
|
|
1226
|
+
async function scanConventions(reader, files, out) {
|
|
1227
|
+
for (const [names, label, why] of TOOLING) {
|
|
1228
|
+
const found = names.find((name) => reader.entries.has(name.toLowerCase()));
|
|
1229
|
+
if (found === void 0) continue;
|
|
1230
|
+
out.conventions.push(`${label} (${found}).`);
|
|
1231
|
+
if (label.startsWith("Biome") || label.startsWith("Prettier") || label.startsWith("rustfmt")) out.advice.push({
|
|
1232
|
+
advice: `Format with the project's formatter, not by hand.`,
|
|
1233
|
+
why: `${found}: ${why}, and a style-only diff is noise in a review.`
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
for (const pinned of [
|
|
1237
|
+
".nvmrc",
|
|
1238
|
+
".node-version",
|
|
1239
|
+
".python-version",
|
|
1240
|
+
".tool-versions"
|
|
1241
|
+
]) {
|
|
1242
|
+
if (!reader.entries.has(pinned)) continue;
|
|
1243
|
+
const value = (await reader.text(pinned))?.split("\n")[0]?.trim();
|
|
1244
|
+
out.conventions.push(`Runtime version pinned: ${value ?? "see file"} (${pinned}).`);
|
|
1245
|
+
}
|
|
1246
|
+
let workflows = [];
|
|
1247
|
+
try {
|
|
1248
|
+
workflows = (await files.listEntries?.(".github/workflows") ?? []).filter((name) => /\.ya?ml$/i.test(name));
|
|
1249
|
+
} catch {
|
|
1250
|
+
workflows = [];
|
|
1251
|
+
}
|
|
1252
|
+
if (workflows.length > 0) out.conventions.push(`CI: GitHub Actions (${workflows.slice(0, 4).join(", ")}).`);
|
|
1253
|
+
const ci = CI_FILES.find((name) => reader.entries.has(name));
|
|
1254
|
+
if (ci !== void 0) out.conventions.push(`CI: ${ci}.`);
|
|
1255
|
+
if (reader.entries.has("dockerfile") || reader.entries.has("docker-compose.yml")) out.conventions.push("Containers: Dockerfile or docker-compose.yml at the root.");
|
|
1256
|
+
if (reader.entries.has("tsconfig.json")) out.conventions.push("TypeScript: tsconfig.json at the root.");
|
|
1257
|
+
}
|
|
1258
|
+
/** Reads the product's root and says what it is. Never throws: an unreadable file says nothing. */
|
|
1259
|
+
async function scanProject(files) {
|
|
1260
|
+
const out = {
|
|
1261
|
+
stack: [],
|
|
1262
|
+
commands: [],
|
|
1263
|
+
conventions: [],
|
|
1264
|
+
layout: [],
|
|
1265
|
+
advice: []
|
|
1266
|
+
};
|
|
1267
|
+
let names = [];
|
|
1268
|
+
try {
|
|
1269
|
+
names = await files.listEntries?.("") ?? [];
|
|
1270
|
+
} catch {
|
|
1271
|
+
names = [];
|
|
1272
|
+
}
|
|
1273
|
+
const byLower = lower(names);
|
|
1274
|
+
const reader = {
|
|
1275
|
+
entries: new Set(byLower.keys()),
|
|
1276
|
+
async text(name) {
|
|
1277
|
+
const actual = byLower.get(name.toLowerCase());
|
|
1278
|
+
if (actual === void 0) return void 0;
|
|
1279
|
+
try {
|
|
1280
|
+
const text = await files.readText(actual);
|
|
1281
|
+
return text.length > MAX_MANIFEST ? void 0 : text.replace(/\r\n/g, "\n");
|
|
1282
|
+
} catch {
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
await scanNode(reader, out);
|
|
1288
|
+
await scanPython(reader, out);
|
|
1289
|
+
scanSimple(reader, out);
|
|
1290
|
+
await scanComposer(reader, out);
|
|
1291
|
+
await scanMake(reader, out);
|
|
1292
|
+
await scanConventions(reader, files, out);
|
|
1293
|
+
const folders = [];
|
|
1294
|
+
for (const name of names) {
|
|
1295
|
+
if (name.includes(".") || name.startsWith("_") || NOT_LAYOUT.has(name.toLowerCase())) continue;
|
|
1296
|
+
if (folders.length >= 30) break;
|
|
1297
|
+
try {
|
|
1298
|
+
if ((await files.listEntries?.(name) ?? []).length > 0) folders.push(name);
|
|
1299
|
+
} catch {}
|
|
1300
|
+
}
|
|
1301
|
+
const tests = folders.filter((name) => TEST_FOLDERS.has(name.toLowerCase()));
|
|
1302
|
+
const product = folders.filter((name) => !TEST_FOLDERS.has(name.toLowerCase()));
|
|
1303
|
+
if (product.length > 0) out.layout.push(`Folders: ${product.slice(0, MAX_LAYOUT).join(", ")}.`);
|
|
1304
|
+
if (tests.length > 0) out.layout.push(`Tests live in: ${tests.join(", ")}.`);
|
|
1305
|
+
if (!out.commands.some((command) => command.name === "test") && out.stack.length > 0) out.advice.push({
|
|
1306
|
+
advice: "No test command was found: ask the creator for it before inventing one.",
|
|
1307
|
+
why: "no manifest or Makefile of the root defines it; a guessed command proves nothing."
|
|
1308
|
+
});
|
|
1309
|
+
return out;
|
|
1310
|
+
}
|
|
1311
|
+
// ../../packages/core/dist/project-profile/project-command.js
|
|
1312
|
+
/** Where the engine keeps the registry it read: the machine's memory, outside Git. */
|
|
1313
|
+
const BRICKS_CACHE_PATH = `${METHOD_DIR}/.cache/bricks.json`;
|
|
1314
|
+
const invalid = (reason) => new AgentPluggedError(ErrorCode.INVALID_ARGUMENTS, "error.invalid_arguments", { reason });
|
|
1315
|
+
async function readText(context, path) {
|
|
1316
|
+
try {
|
|
1317
|
+
return await context.files.exists(path) ? await context.files.readText(path) : void 0;
|
|
1318
|
+
} catch {
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/** The single sources the team declared (the table of the profile), empty without a profile. */
|
|
1323
|
+
async function singleSourcesOf(context) {
|
|
1324
|
+
const text = await readText(context, PROFILE_PATH);
|
|
1325
|
+
return text === void 0 ? [] : parseKept(text).sources;
|
|
1326
|
+
}
|
|
1327
|
+
/** The profile file for a scan, the parts people wrote kept from `current`. */
|
|
1328
|
+
function profileFile(scan, current) {
|
|
1329
|
+
return renderProfile(scan, current === void 0 ? {
|
|
1330
|
+
sources: [],
|
|
1331
|
+
acceptance: "",
|
|
1332
|
+
notes: ""
|
|
1333
|
+
} : parseKept(current));
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* `docs profile [refresh]`: the project profile. Without `refresh`, the file as it would be written
|
|
1337
|
+
* now (and whether the file on disk still says the same); with it, the file is written. The profile
|
|
1338
|
+
* is read from the root files of the product, so it is never invented.
|
|
1339
|
+
*/
|
|
1340
|
+
async function showProfile(context, words) {
|
|
1341
|
+
if (words[0] === "accept") return declareAcceptance(context, words.slice(1));
|
|
1342
|
+
if (words.length > 1 || words.length === 1 && words[0] !== "refresh") return invalid(`docs profile ${words.join(" ")} (profile [refresh], or profile accept <local|staging> [how])`);
|
|
1343
|
+
const current = await readText(context, PROFILE_PATH);
|
|
1344
|
+
const fresh = profileFile(await scanProject(context.files), current);
|
|
1345
|
+
const same = current !== void 0 && current.replace(/\r\n/g, "\n") === fresh;
|
|
1346
|
+
if (words[0] === "refresh") {
|
|
1347
|
+
if (!same) await context.files.writeText(PROFILE_PATH, fresh);
|
|
1348
|
+
return { profile: {
|
|
1349
|
+
path: PROFILE_PATH,
|
|
1350
|
+
written: !same,
|
|
1351
|
+
state: current === void 0 ? "created" : same ? "current" : "refreshed",
|
|
1352
|
+
lines: fresh.split("\n")
|
|
1353
|
+
} };
|
|
1354
|
+
}
|
|
1355
|
+
return { profile: {
|
|
1356
|
+
path: PROFILE_PATH,
|
|
1357
|
+
written: false,
|
|
1358
|
+
state: current === void 0 ? "absent" : same ? "current" : "stale",
|
|
1359
|
+
lines: fresh.split("\n")
|
|
1360
|
+
} };
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* `docs profile accept <local|staging> [how…]`: where the acceptance run happens, decided with the
|
|
1364
|
+
* creator and kept in the profile (a refresh never overwrites it).
|
|
1365
|
+
*/
|
|
1366
|
+
async function declareAcceptance(context, words) {
|
|
1367
|
+
const [place, ...how] = words;
|
|
1368
|
+
if (place !== "local" && place !== "staging") return invalid("docs profile accept <local|staging> [how to reach it]");
|
|
1369
|
+
const current = await readText(context, PROFILE_PATH);
|
|
1370
|
+
const kept = current === void 0 ? {
|
|
1371
|
+
sources: [],
|
|
1372
|
+
acceptance: "",
|
|
1373
|
+
notes: ""
|
|
1374
|
+
} : parseKept(current);
|
|
1375
|
+
const fresh = renderProfile(await scanProject(context.files), {
|
|
1376
|
+
...kept,
|
|
1377
|
+
acceptance: acceptanceText(place, how.join(" "))
|
|
1378
|
+
});
|
|
1379
|
+
await context.files.writeText(PROFILE_PATH, fresh);
|
|
1380
|
+
return { profile: {
|
|
1381
|
+
path: PROFILE_PATH,
|
|
1382
|
+
written: true,
|
|
1383
|
+
state: "refreshed",
|
|
1384
|
+
lines: fresh.split("\n")
|
|
1385
|
+
} };
|
|
1386
|
+
}
|
|
1387
|
+
/** The bricks at the commit the folder is on: read from the cache, or read again when it moved. */
|
|
1388
|
+
async function loadBricks(context) {
|
|
1389
|
+
if (context.codeSource === void 0 || context.docsBase === void 0) return "no_code_source";
|
|
1390
|
+
const head = await context.docsBase.read("HEAD", [PROFILE_PATH]).catch(() => void 0);
|
|
1391
|
+
if (head === void 0) return "no_repository";
|
|
1392
|
+
if (!head.found) return head.reason === "no_commit" ? "no_commit" : "no_repository";
|
|
1393
|
+
const cached = await readText(context, BRICKS_CACHE_PATH);
|
|
1394
|
+
if (cached !== void 0) try {
|
|
1395
|
+
const parsed = JSON.parse(cached);
|
|
1396
|
+
if (parsed.v === 1 && parsed.commit === head.commit && Array.isArray(parsed.bricks)) return {
|
|
1397
|
+
bricks: parsed.bricks.flatMap((row) => typeof row[0] === "string" && (row[1] === "fn" || row[1] === "type") && typeof row[2] === "string" && Number.isInteger(row[3]) && typeof row[4] === "string" ? [{
|
|
1398
|
+
name: row[0],
|
|
1399
|
+
kind: row[1],
|
|
1400
|
+
path: row[2],
|
|
1401
|
+
line: row[3],
|
|
1402
|
+
sig: row[4]
|
|
1403
|
+
}] : []),
|
|
1404
|
+
commit: head.commit
|
|
1405
|
+
};
|
|
1406
|
+
} catch {}
|
|
1407
|
+
const tree = await context.codeSource.read(head.commit, (path, bytes) => isCodePath(path) && bytes <= BRICK_FILE_BYTES).catch(() => void 0);
|
|
1408
|
+
if (tree === void 0) return "no_repository";
|
|
1409
|
+
const bricks = buildBricks(tree);
|
|
1410
|
+
await context.files.writeText(BRICKS_CACHE_PATH, JSON.stringify({
|
|
1411
|
+
v: 1,
|
|
1412
|
+
commit: head.commit,
|
|
1413
|
+
bricks: bricks.map((brick) => [
|
|
1414
|
+
brick.name,
|
|
1415
|
+
brick.kind,
|
|
1416
|
+
brick.path,
|
|
1417
|
+
brick.line,
|
|
1418
|
+
brick.sig
|
|
1419
|
+
])
|
|
1420
|
+
})).catch(() => void 0);
|
|
1421
|
+
return {
|
|
1422
|
+
bricks,
|
|
1423
|
+
commit: head.commit
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* `docs bricks <words>`: does something like this already exist? The bricks (functions and types)
|
|
1428
|
+
* whose names carry the words, best first, and the single source that owns the concept if one is
|
|
1429
|
+
* declared. `docs bricks own <concept> <path> [rule…]` declares a single source: the concept then
|
|
1430
|
+
* has one home, and a second one added by a story is flagged at its review.
|
|
1431
|
+
*/
|
|
1432
|
+
async function findInRegistry(context, words) {
|
|
1433
|
+
if (words[0] === "own") return declareSource(context, words.slice(1));
|
|
1434
|
+
if (words.length === 0) return invalid("docs bricks <words> | docs bricks own <concept> <path>");
|
|
1435
|
+
const named = matchingSources(await singleSourcesOf(context), words);
|
|
1436
|
+
const loaded = await loadBricks(context);
|
|
1437
|
+
if (typeof loaded === "string") return { bricks: {
|
|
1438
|
+
query: words,
|
|
1439
|
+
hits: [],
|
|
1440
|
+
sources: named,
|
|
1441
|
+
unavailable: loaded
|
|
1442
|
+
} };
|
|
1443
|
+
return { bricks: {
|
|
1444
|
+
query: words,
|
|
1445
|
+
hits: findBricks(loaded.bricks, words).map(({ brick }) => ({
|
|
1446
|
+
name: brick.name,
|
|
1447
|
+
kind: brick.kind,
|
|
1448
|
+
path: brick.path,
|
|
1449
|
+
line: brick.line,
|
|
1450
|
+
sig: brick.sig
|
|
1451
|
+
})),
|
|
1452
|
+
sources: named,
|
|
1453
|
+
searched: {
|
|
1454
|
+
total: loaded.bricks.length,
|
|
1455
|
+
commit: loaded.commit.slice(0, 12)
|
|
1456
|
+
}
|
|
1457
|
+
} };
|
|
1458
|
+
}
|
|
1459
|
+
async function declareSource(context, words) {
|
|
1460
|
+
const [concept, owner, ...rule] = words;
|
|
1461
|
+
if (concept === void 0 || owner === void 0 || concept.length < 2) return invalid("docs bricks own <concept> <path> [rule…]");
|
|
1462
|
+
const bare = owner.replaceAll("\\", "/").split("/").filter((part) => part !== "" && part !== ".").join("/");
|
|
1463
|
+
if (bare === "" || bare.startsWith("/") || bare.split("/").includes("..")) return invalid(`docs bricks own ${concept} ${owner} (a path of the product)`);
|
|
1464
|
+
if (!await context.files.exists(bare)) return invalid(`docs bricks own ${concept} ${owner} (no such file or folder)`);
|
|
1465
|
+
const source = {
|
|
1466
|
+
concept: concept.replace(/[|\r\n]+/g, " ").trim(),
|
|
1467
|
+
owner: (await context.files.listEntries?.(bare) ?? []).length > 0 ? `${bare}/` : bare,
|
|
1468
|
+
rule: rule.join(" ").slice(0, 160)
|
|
1469
|
+
};
|
|
1470
|
+
const current = await readText(context, PROFILE_PATH);
|
|
1471
|
+
const scan = await scanProject(context.files);
|
|
1472
|
+
const kept = withSource(current === void 0 ? {
|
|
1473
|
+
sources: [],
|
|
1474
|
+
acceptance: "",
|
|
1475
|
+
notes: ""
|
|
1476
|
+
} : parseKept(current), source);
|
|
1477
|
+
await context.files.writeText(PROFILE_PATH, renderProfile(scan, kept));
|
|
1478
|
+
return { bricks: {
|
|
1479
|
+
query: [concept],
|
|
1480
|
+
hits: [],
|
|
1481
|
+
sources: [source],
|
|
1482
|
+
declared: source
|
|
1483
|
+
} };
|
|
1484
|
+
}
|
|
1485
|
+
/** Longest list of each kind in the brief. */
|
|
1486
|
+
const BRIEF_ROWS = 6;
|
|
1487
|
+
/**
|
|
1488
|
+
* The profile brief for a story: read from the root files now (a stale file never misleads) with the
|
|
1489
|
+
* single sources of the profile. Undefined when the product has no stack to describe yet.
|
|
1490
|
+
*/
|
|
1491
|
+
async function profileBrief(context) {
|
|
1492
|
+
try {
|
|
1493
|
+
const scan = await scanProject(context.files);
|
|
1494
|
+
if (scan.stack.length === 0 && scan.commands.length === 0) return void 0;
|
|
1495
|
+
const sources = await singleSourcesOf(context);
|
|
1496
|
+
return {
|
|
1497
|
+
path: PROFILE_PATH,
|
|
1498
|
+
stack: scan.stack.slice(0, BRIEF_ROWS).map((row) => row.slice(0, 140)),
|
|
1499
|
+
commands: scan.commands.slice(0, 12).map((c) => `${c.name}: ${c.command}`),
|
|
1500
|
+
single_sources: sources.slice(0, 10).map((s) => `${s.concept} -> ${s.owner}${s.rule === "" ? "" : ` (${s.rule})`}`),
|
|
1501
|
+
advice: scan.advice.slice(0, 3).map((a) => `${a.advice} Why: ${a.why}`.slice(0, 220)),
|
|
1502
|
+
ask: "npx agentplugged docs bricks <words>"
|
|
1503
|
+
};
|
|
1504
|
+
} catch {
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
export { REFERENCE_DIR as A, METHOD_CONFIG_PATH as C, METHOD_SUBDIRS as D, METHOD_GITIGNORE_PATH as E, ROOT_CONSTITUTION_PATH as M, referenceFilePath as N, MODELS_PATH as O, unexpected as S, METHOD_DIR as T, sourceWords as _, showProfile as a, reasonDetails as b, PROFILE_PATH as c, findTwins as d, MAX_FILE_BYTES as f, WordTable as g, isTestPath as h, profileFile as i, REFERENCE_LEGACY_PATH as j, POINTER_FILES as k, renderProfile as l, isReadableCode as m, findInRegistry as n, singleSourcesOf as o, isCodePath as p, profileBrief as r, scanProject as s, BRICKS_CACHE_PATH as t, sourceClashes as u, wordsOf as v, METHOD_CONSTITUTION_PATH as w, unavailable as x, AgentPluggedError as y };
|